UNPKG

ltijs

Version:

Easily turn your web application into a LTI 1.3 Learning Tool.

224 lines • 11.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.OidcService = void 0; const jwt_1 = require("#utils/crypto/jwt"); const jwt_constants_1 = require("#utils/crypto/jwt.constants"); const keys_1 = require("#utils/crypto/keys"); const signed_value_1 = require("#utils/crypto/signed-value"); const validation_1 = require("#utils/validation/validation"); const random_1 = require("#utils/random/random"); const cache_manager_utils_1 = require("#services/cache-manager/cache-manager.utils"); const oidc_schemas_1 = require("#services/oidc/oidc.schemas"); const id_token_constants_1 = require("#services/launch/id-token.constants"); const errors_1 = require("#services/oidc/errors"); const platform_manager_constants_1 = require("#services/platform-manager/platform-manager.constants"); const platform_keys_1 = require("#services/platform-manager/platform-keys"); class OidcService { LOG_COMPONENT = 'oidcService'; STATE_TTL_SECONDS = 600; OIDC_RESPONSE_TYPE = 'id_token'; OIDC_RESPONSE_MODE = 'form_post'; OIDC_SCOPE = 'openid'; OIDC_PROMPT = 'none'; JWKS_CACHE_TTL_MS = 5 * 60 * 1000; JWKS_CACHE_KEY_PREFIX = 'oidc:jwks:'; databaseManager; requestHandler; cacheManager; logger; tokenMaxAge; // eslint-disable-next-line @typescript-eslint/max-params constructor(databaseManager, requestHandler, cacheManager, logger, tokenMaxAge) { this.databaseManager = databaseManager; this.requestHandler = requestHandler; this.cacheManager = cacheManager; this.logger = logger; this.tokenMaxAge = tokenMaxAge; } async validateIdToken(rawIdToken, header, platform) { const key = await this.resolveVerificationKey(platform.idTokenValidation, header.kid); const verifiedToken = (0, jwt_1.verifyTokenSignature)(rawIdToken, key, [jwt_constants_1.RS256_ALGORITHM]); const validatedToken = this.claimValidation(verifiedToken); await this.runAdditionalOidcValidation(validatedToken, header, platform); this.logger.debug(this.LOG_COMPONENT, `Token validated for [${validatedToken.iss}]`); return validatedToken; } // `stateId` defaults to a fresh random value when the caller doesn't already have one to correlate; // callers that also mint a matching buildRecoveryToken() (LaunchService.processLogin) generate it // themselves first and pass it to both, so the two tokens share the same id. buildStateToken(platform, target = {}, storage, stateId = (0, random_1.randomUuid)()) { return (0, jwt_1.signJwt)({ stateId, query: target.query, fragment: target.fragment, storageTarget: storage?.target, platformLoginOrigin: storage?.loginOrigin, }, (0, platform_keys_1.resolvePlatformPrivateKey)(platform), { algorithm: jwt_constants_1.RS256_ALGORITHM, expiresIn: this.STATE_TTL_SECONDS, }); } async validateStateToken(token, platform) { try { return (0, jwt_1.verifyTokenSignature)(token, (0, platform_keys_1.resolvePlatformPublicKey)(platform), [jwt_constants_1.RS256_ALGORITHM]); } catch { throw new errors_1.InvalidStateError(); } } // A second, independently-signed value carrying the same stateId as buildStateToken()'s. Unlike // `state`, this one is never sent to the platform, only ever delivered to the browser for client-side // storage (localStorage/postMessage) and echoed back on launch. Verifying it proves the recovered value // was actually issued by this server at login time, not just copied from the state field of the same // forged request. HMAC-signed rather than a JWT: nothing but this server ever verifies it, so there's // no need for asymmetric signing, and as a side effect, an HMAC token can never be mistaken for a // JWT (or vice versa), so there's no risk of `state` itself being replayed in its place. buildRecoveryToken(stateId, platform) { const expiresAt = Date.now() + this.STATE_TTL_SECONDS * 1000; return (0, signed_value_1.signValue)(`${stateId}:${expiresAt}`, (0, platform_keys_1.resolvePlatformPrivateKey)(platform)); } verifyRecoveryToken(token, platform) { const value = (0, signed_value_1.verifySignedValue)(token, (0, platform_keys_1.resolvePlatformPrivateKey)(platform)); if (value === undefined) throw new errors_1.InvalidStateError(); const [stateId, expiresAtRaw] = value.split(':'); const expiresAt = Number(expiresAtRaw); if (stateId === undefined || stateId === '' || !Number.isFinite(expiresAt) || Date.now() > expiresAt) { throw new errors_1.InvalidStateError(); } return stateId; } // The full "is this recovered value acceptable for this launch" check, kept in one place rather than // split between here and LaunchService: verifies the recovery token itself, then confirms its stateId // matches the state token's. `stateToken`'s own signature isn't verified here (it's checked // separately, in validateStateToken, as part of the normal launch flow); this is only reading its // stateId claim back out, mirroring how peekStateToken() already peeks at state before its signature // is checked. verifyRecoveredState(recoveredToken, stateToken, platform) { const recoveredStateId = this.verifyRecoveryToken(recoveredToken, platform); const { stateId } = (0, jwt_1.decodeToken)(stateToken).payload; if (recoveredStateId !== stateId) throw new errors_1.InvalidStateError(); } async buildAuthenticationRequestUrl(platform, request) { const nonce = (0, random_1.randomUuid)(); await this.databaseManager.saveNonce(nonce); const query = this.buildAuthenticationRequestQuery(platform, request, nonce); return this.buildRedirectUrl(platform.authenticationEndpoint, query); } buildAuthenticationRequestQuery(platform, request, nonce) { const query = { response_type: this.OIDC_RESPONSE_TYPE, response_mode: this.OIDC_RESPONSE_MODE, id_token_signed_response_alg: jwt_constants_1.RS256_ALGORITHM, scope: this.OIDC_SCOPE, client_id: platform.clientId, redirect_uri: request.redirectUri, login_hint: request.loginHint, nonce, prompt: this.OIDC_PROMPT, state: request.state, }; if (request.ltiMessageHint !== undefined) query.lti_message_hint = request.ltiMessageHint; if (request.ltiDeploymentId !== undefined) query.lti_deployment_id = request.ltiDeploymentId; return query; } buildRedirectUrl(authenticationEndpoint, query) { const url = new URL(authenticationEndpoint); for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value); return url.toString(); } claimValidation(token) { const schema = this.resolveClaimSchema(token[id_token_constants_1.IdTokenClaim.MessageType]); return (0, validation_1.validate)(schema, token); } resolveClaimSchema(messageType) { switch (messageType) { case id_token_constants_1.LtiMessageType.ResourceLinkRequest: return oidc_schemas_1.ResourceLinkRequestSchema; case id_token_constants_1.LtiMessageType.DeepLinkingRequest: return oidc_schemas_1.DeepLinkingRequestSchema; case id_token_constants_1.LtiMessageType.SubmissionReviewRequest: return oidc_schemas_1.SubmissionReviewRequestSchema; default: throw new errors_1.InvalidMessageTypeError(); } } async resolveVerificationKey(idTokenValidation, kid) { switch (idTokenValidation.method) { case platform_manager_constants_1.IdTokenValidationMethod.RsaKey: return idTokenValidation.key; case platform_manager_constants_1.IdTokenValidationMethod.JwkKey: return (0, keys_1.jwkToRsa)(JSON.parse(idTokenValidation.key)); case platform_manager_constants_1.IdTokenValidationMethod.JwkSet: { const jwk = await this.resolveJwk(idTokenValidation.key, kid); return (0, keys_1.jwkToRsa)(jwk); } default: throw new errors_1.AuthconfigNotFoundError(); } } async resolveJwk(jwksUri, kid) { const keys = await this.resolveJwks(jwksUri); const jwk = keys.find(candidate => candidate.kid === kid); if (jwk !== undefined) return jwk; await this.cacheManager.delete(this.buildJwksCacheKey(jwksUri)); const freshKeys = await this.resolveJwks(jwksUri); const freshJwk = freshKeys.find(candidate => candidate.kid === kid); if (freshJwk === undefined) throw new errors_1.AuthconfigNotFoundError(); return freshJwk; } buildJwksCacheKey(jwksUri) { return `${this.JWKS_CACHE_KEY_PREFIX}${jwksUri}`; } async resolveJwks(jwksUri) { const cacheKey = this.buildJwksCacheKey(jwksUri); const cached = await (0, cache_manager_utils_1.safeCacheGet)(this.cacheManager, cacheKey); if (cached !== undefined) return cached; const response = await this.requestHandler.get(jwksUri); const jwks = (0, validation_1.validate)(oidc_schemas_1.JwksResponseSchema, response.data); await (0, cache_manager_utils_1.safeCacheSet)(this.cacheManager, cacheKey, jwks.keys, this.JWKS_CACHE_TTL_MS); return jwks.keys; } async runAdditionalOidcValidation(token, header, platform) { await Promise.all([ this.validateAud(token, platform), this.validateMaxAge(token), this.validateNonce(token), this.validateAlg(header), ]); } async validateAud(token, platform) { // azp is only spec-required (and only meaningfully checked) when aud actually names more than one // audience. A single-element array is equivalent to a bare string aud, where a platform sending // no azp at all is spec-valid and must not be rejected. const hasMultipleAudiences = Array.isArray(token.aud) && token.aud.length > 1; if (hasMultipleAudiences && token.azp !== platform.clientId) { throw new errors_1.AzpDoesNotMatchClientidError(); } } async validateMaxAge(token) { if (this.tokenMaxAge === false) return; if (Date.now() / 1000 - token.iat > this.tokenMaxAge) throw new errors_1.TokenTooOldError(); } async validateAlg(header) { if (header.alg !== jwt_constants_1.RS256_ALGORITHM) throw new errors_1.InvalidAlgError(); } async validateNonce(token) { const consumed = await this.databaseManager.consumeNonce(token.nonce); if (!consumed) throw new errors_1.InvalidNonceError(); } } exports.OidcService = OidcService; exports.default = OidcService; //# sourceMappingURL=oidc.service.js.map