ltijs
Version:
Easily turn your web application into a LTI 1.3 Learning Tool.
224 lines • 11.3 kB
JavaScript
;
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