-
-
Notifications
You must be signed in to change notification settings - Fork 461
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): implement enterprise sso verification flow (#6198)
implement the enterprise sso verification flow
- Loading branch information
Showing
10 changed files
with
611 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
208 changes: 208 additions & 0 deletions
208
packages/core/src/routes/experience/classes/verifications/enterprise-sso-verification.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,208 @@ | ||
import { socialUserInfoGuard, type SocialUserInfo, type ToZodObject } from '@logto/connector-kit'; | ||
import { | ||
VerificationType, | ||
type JsonObject, | ||
type SocialAuthorizationUrlPayload, | ||
type SupportedSsoConnector, | ||
type User, | ||
type UserSsoIdentity, | ||
} from '@logto/schemas'; | ||
import { generateStandardId } from '@logto/shared'; | ||
import { z } from 'zod'; | ||
|
||
import RequestError from '#src/errors/RequestError/index.js'; | ||
import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; | ||
import { | ||
getSsoAuthorizationUrl, | ||
verifySsoIdentity, | ||
} from '#src/routes/interaction/utils/single-sign-on.js'; | ||
import type Libraries from '#src/tenants/Libraries.js'; | ||
import type Queries from '#src/tenants/Queries.js'; | ||
import type TenantContext from '#src/tenants/TenantContext.js'; | ||
import assertThat from '#src/utils/assert-that.js'; | ||
|
||
import { type VerificationRecord } from './verification-record.js'; | ||
|
||
/** The JSON data type for the EnterpriseSsoVerification record stored in the interaction storage */ | ||
export type EnterpriseSsoVerificationRecordData = { | ||
id: string; | ||
connectorId: string; | ||
type: VerificationType.EnterpriseSso; | ||
/** | ||
* The enterprise SSO identity returned by the connector. | ||
*/ | ||
enterpriseSsoUserInfo?: SocialUserInfo; | ||
issuer?: string; | ||
}; | ||
|
||
export const enterPriseSsoVerificationRecordDataGuard = z.object({ | ||
id: z.string(), | ||
connectorId: z.string(), | ||
type: z.literal(VerificationType.EnterpriseSso), | ||
enterpriseSsoUserInfo: socialUserInfoGuard.optional(), | ||
issuer: z.string().optional(), | ||
}) satisfies ToZodObject<EnterpriseSsoVerificationRecordData>; | ||
|
||
export class EnterpriseSsoVerification | ||
implements VerificationRecord<VerificationType.EnterpriseSso> | ||
{ | ||
static create(libraries: Libraries, queries: Queries, connectorId: string) { | ||
return new EnterpriseSsoVerification(libraries, queries, { | ||
id: generateStandardId(), | ||
connectorId, | ||
type: VerificationType.EnterpriseSso, | ||
}); | ||
} | ||
|
||
public readonly id: string; | ||
public readonly type = VerificationType.EnterpriseSso; | ||
public readonly connectorId: string; | ||
public enterpriseSsoUserInfo?: SocialUserInfo; | ||
public issuer?: string; | ||
|
||
private connectorDataCache?: SupportedSsoConnector; | ||
|
||
constructor( | ||
private readonly libraries: Libraries, | ||
private readonly queries: Queries, | ||
data: EnterpriseSsoVerificationRecordData | ||
) { | ||
const { id, connectorId, enterpriseSsoUserInfo, issuer } = | ||
enterPriseSsoVerificationRecordDataGuard.parse(data); | ||
|
||
this.id = id; | ||
this.connectorId = connectorId; | ||
this.enterpriseSsoUserInfo = enterpriseSsoUserInfo; | ||
this.issuer = issuer; | ||
} | ||
|
||
/** Returns true if the enterprise SSO identity has been verified */ | ||
get isVerified() { | ||
return Boolean(this.enterpriseSsoUserInfo && this.issuer); | ||
} | ||
|
||
async getConnectorData(connectorId: string) { | ||
this.connectorDataCache ||= await this.libraries.ssoConnectors.getSsoConnectorById(connectorId); | ||
|
||
return this.connectorDataCache; | ||
} | ||
|
||
/** | ||
* Create the authorization URL for the enterprise SSO connector. | ||
* | ||
* @remarks | ||
* Refers to thr {@link getSsoAuthorizationUrl} function in the interaction/utils/single-sign-on.ts file. | ||
* Currently, all the intermediate connector session results are stored in the provider's interactionDetails separately, | ||
* apart from the new verification record. | ||
* For compatibility reasons, we keep using the old {@link getSsoAuthorizationUrl} method here as a single source of truth. | ||
* Especially for the SAML connectors, | ||
* SAML ACS endpoint will find the connector session result by the jti and assign it to the interaction storage. | ||
* We will need to update the SAML ACS endpoint before move the logic to this new EnterpriseSsoVerification class. | ||
*/ | ||
async createAuthorizationUrl( | ||
ctx: WithLogContext, | ||
tenantContext: TenantContext, | ||
payload: SocialAuthorizationUrlPayload | ||
) { | ||
const connectorData = await this.getConnectorData(this.connectorId); | ||
return getSsoAuthorizationUrl(ctx, tenantContext, connectorData, payload); | ||
} | ||
|
||
/** | ||
* Verify the enterprise SSO identity and store the enterprise SSO identity in the verification record. | ||
* | ||
* @remarks | ||
* Refers to the {@link verifySsoIdentity} function in the interaction/utils/single-sign-on.ts file. | ||
* For compatibility reasons, we keep using the old {@link verifySsoIdentity} method here as a single source of truth. | ||
* See the above {@link createAuthorizationUrl} method for more details. | ||
*/ | ||
async verify(ctx: WithLogContext, tenantContext: TenantContext, callbackData: JsonObject) { | ||
const connectorData = await this.getConnectorData(this.connectorId); | ||
const { issuer, userInfo } = await verifySsoIdentity( | ||
ctx, | ||
tenantContext, | ||
connectorData, | ||
callbackData | ||
); | ||
|
||
this.issuer = issuer; | ||
this.enterpriseSsoUserInfo = userInfo; | ||
} | ||
|
||
async identifyUser(): Promise<User> { | ||
assertThat( | ||
this.isVerified, | ||
new RequestError({ code: 'session.verification_failed', status: 422 }) | ||
); | ||
|
||
// TODO: sync userInfo and link sso identity | ||
|
||
const userSsoIdentityResult = await this.findUserSsoIdentityByEnterpriseSsoUserInfo(); | ||
|
||
if (userSsoIdentityResult) { | ||
return userSsoIdentityResult.user; | ||
} | ||
|
||
const relatedUser = await this.findRelatedUserSsoIdentity(); | ||
|
||
if (relatedUser) { | ||
return relatedUser; | ||
} | ||
|
||
throw new RequestError({ code: 'user.identity_not_exist', status: 404 }); | ||
} | ||
|
||
toJson(): EnterpriseSsoVerificationRecordData { | ||
const { id, connectorId, type, enterpriseSsoUserInfo, issuer } = this; | ||
|
||
return { | ||
id, | ||
connectorId, | ||
type, | ||
enterpriseSsoUserInfo, | ||
issuer, | ||
}; | ||
} | ||
|
||
private async findUserSsoIdentityByEnterpriseSsoUserInfo(): Promise< | ||
| { | ||
user: User; | ||
userSsoIdentity: UserSsoIdentity; | ||
} | ||
| undefined | ||
> { | ||
const { userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries } = this.queries; | ||
|
||
if (!this.issuer || !this.enterpriseSsoUserInfo) { | ||
return; | ||
} | ||
|
||
const userSsoIdentity = await userSsoIdentitiesQueries.findUserSsoIdentityBySsoIdentityId( | ||
this.issuer, | ||
this.enterpriseSsoUserInfo.id | ||
); | ||
|
||
if (userSsoIdentity) { | ||
const user = await usersQueries.findUserById(userSsoIdentity.userId); | ||
return { | ||
user, | ||
userSsoIdentity, | ||
}; | ||
} | ||
} | ||
|
||
/** | ||
* Find the related user by the enterprise SSO identity's verified email. | ||
*/ | ||
private async findRelatedUserSsoIdentity(): Promise<User | undefined> { | ||
const { users: usersQueries } = this.queries; | ||
|
||
if (!this.enterpriseSsoUserInfo?.email) { | ||
return; | ||
} | ||
|
||
const user = await usersQueries.findUserByEmail(this.enterpriseSsoUserInfo.email); | ||
|
||
return user ?? undefined; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.