Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add token introspection endpoint #906

Merged
merged 15 commits into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/build/framework/request.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export declare abstract class BaseRequest {
abstract getOriginalURL: () => string;
getFormData: () => Promise<any>;
getJSONBody: () => Promise<any>;
getBodyAsJSONOrFormData: () => Promise<any>;
}
20 changes: 20 additions & 0 deletions lib/build/framework/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,26 @@ class BaseRequest {
}
return this.parsedJSONBody;
};
this.getBodyAsJSONOrFormData = async () => {
const contentType = this.getHeaderValue("content-type");
if (contentType) {
if (contentType.startsWith("application/json")) {
return await this.getJSONBody();
} else if (contentType.startsWith("application/x-www-form-urlencoded")) {
return await this.getFormData();
}
} else {
try {
return await this.getJSONBody();
} catch (_a) {
try {
return await this.getFormData();
} catch (_b) {
throw new Error("Unable to parse body as JSON or Form Data.");
}
}
}
};
this.wrapperUsed = true;
this.parsedJSONBody = undefined;
this.parsedUrlEncodedFormData = undefined;
Expand Down
8 changes: 8 additions & 0 deletions lib/build/recipe/oauth2provider/api/implementation.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function getAPIImplementation() {
},
tokenPOST: async (input) => {
return input.options.recipeImplementation.tokenExchange({
authorizationHeader: input.authorizationHeader,
body: input.body,
userContext: input.userContext,
});
Expand Down Expand Up @@ -95,6 +96,13 @@ function getAPIImplementation() {
});
}
},
introspectTokenPOST: async (input) => {
return input.options.recipeImplementation.introspectToken({
token: input.token,
scopes: input.scopes,
userContext: input.userContext,
});
},
};
}
exports.default = getAPIImplementation;
8 changes: 8 additions & 0 deletions lib/build/recipe/oauth2provider/api/introspectToken.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// @ts-nocheck
import { APIInterface, APIOptions } from "..";
import { UserContext } from "../../../types";
export default function introspectTokenPOST(
apiImplementation: APIInterface,
options: APIOptions,
userContext: UserContext
): Promise<boolean>;
37 changes: 37 additions & 0 deletions lib/build/recipe/oauth2provider/api/introspectToken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use strict";
/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
Object.defineProperty(exports, "__esModule", { value: true });
const utils_1 = require("../../../utils");
async function introspectTokenPOST(apiImplementation, options, userContext) {
if (apiImplementation.introspectTokenPOST === undefined) {
return false;
}
const body = await options.req.getBodyAsJSONOrFormData();
if (body.token === undefined) {
utils_1.sendNon200ResponseWithMessage(options.res, "token is required in the request body", 400);
return true;
}
const scopes = body.scope ? body.scope.split(" ") : [];
let response = await apiImplementation.introspectTokenPOST({
options,
token: body.token,
scopes,
userContext,
});
utils_1.send200Response(options.res, response);
return true;
}
exports.default = introspectTokenPOST;
2 changes: 1 addition & 1 deletion lib/build/recipe/oauth2provider/api/revokeToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ async function revokeTokenPOST(apiImplementation, options, userContext) {
if (apiImplementation.revokeTokenPOST === undefined) {
return false;
}
const body = await options.req.getFormData();
const body = await options.req.getBodyAsJSONOrFormData();
if (body.token === undefined) {
utils_1.sendNon200ResponseWithMessage(options.res, "token is required in the request body", 400);
return true;
Expand Down
4 changes: 3 additions & 1 deletion lib/build/recipe/oauth2provider/api/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ async function tokenPOST(apiImplementation, options, userContext) {
if (apiImplementation.tokenPOST === undefined) {
return false;
}
const authorizationHeader = options.req.getHeaderValue("authorization");
let response = await apiImplementation.tokenPOST({
authorizationHeader,
options,
body: await options.req.getFormData(),
body: await options.req.getBodyAsJSONOrFormData(),
userContext,
});
if ("statusCode" in response && response.statusCode !== 200) {
Expand Down
1 change: 1 addition & 0 deletions lib/build/recipe/oauth2provider/constants.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export declare const TOKEN_PATH = "/oauth/token";
export declare const LOGIN_INFO_PATH = "/oauth/login/info";
export declare const USER_INFO_PATH = "/oauth/userinfo";
export declare const REVOKE_TOKEN_PATH = "/oauth/revoke";
export declare const INTROSPECT_TOKEN_PATH = "/oauth/introspect";
3 changes: 2 additions & 1 deletion lib/build/recipe/oauth2provider/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@
* under the License.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.REVOKE_TOKEN_PATH = exports.USER_INFO_PATH = exports.LOGIN_INFO_PATH = exports.TOKEN_PATH = exports.AUTH_PATH = exports.LOGIN_PATH = exports.OAUTH2_BASE_PATH = void 0;
exports.INTROSPECT_TOKEN_PATH = exports.REVOKE_TOKEN_PATH = exports.USER_INFO_PATH = exports.LOGIN_INFO_PATH = exports.TOKEN_PATH = exports.AUTH_PATH = exports.LOGIN_PATH = exports.OAUTH2_BASE_PATH = void 0;
exports.OAUTH2_BASE_PATH = "/oauth/";
exports.LOGIN_PATH = "/oauth/login";
exports.AUTH_PATH = "/oauth/auth";
exports.TOKEN_PATH = "/oauth/token";
exports.LOGIN_INFO_PATH = "/oauth/login/info";
exports.USER_INFO_PATH = "/oauth/userinfo";
exports.REVOKE_TOKEN_PATH = "/oauth/revoke";
exports.INTROSPECT_TOKEN_PATH = "/oauth/introspect";
5 changes: 5 additions & 0 deletions lib/build/recipe/oauth2provider/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ export default class Wrapper {
status: "OK";
}
>;
static validateOAuth2RefreshToken(
token: string,
scopes?: string[],
userContext?: Record<string, any>
): Promise<import("./types").InstrospectTokenResponse>;
}
export declare let init: typeof Recipe.init;
export declare let getOAuth2Clients: typeof Wrapper.getOAuth2Clients;
Expand Down
7 changes: 7 additions & 0 deletions lib/build/recipe/oauth2provider/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ class Wrapper {
userContext: normalisedUserContext,
});
}
static validateOAuth2RefreshToken(token, scopes, userContext) {
return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.introspectToken({
token,
scopes,
userContext: utils_1.getUserContext(userContext),
});
}
}
exports.default = Wrapper;
Wrapper.init = recipe_1.default.init;
Expand Down
10 changes: 10 additions & 0 deletions lib/build/recipe/oauth2provider/recipe.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const supertokens_js_override_1 = __importDefault(require("supertokens-js-overri
const userInfo_1 = __importDefault(require("./api/userInfo"));
const combinedRemoteJWKSet_1 = require("../../combinedRemoteJWKSet");
const revokeToken_1 = __importDefault(require("./api/revokeToken"));
const introspectToken_1 = __importDefault(require("./api/introspectToken"));
class Recipe extends recipeModule_1.default {
constructor(recipeId, appInfo, isInServerlessEnv, config) {
super(recipeId, appInfo);
Expand Down Expand Up @@ -70,6 +71,9 @@ class Recipe extends recipeModule_1.default {
if (id === constants_1.REVOKE_TOKEN_PATH) {
return revokeToken_1.default(this.apiImpl, options, userContext);
}
if (id === constants_1.INTROSPECT_TOKEN_PATH) {
return introspectToken_1.default(this.apiImpl, options, userContext);
}
throw new Error("Should never come here: handleAPIRequest called with unknown id");
};
this.config = utils_1.validateAndNormaliseUserInput(this, appInfo, config);
Expand Down Expand Up @@ -157,6 +161,12 @@ class Recipe extends recipeModule_1.default {
id: constants_1.REVOKE_TOKEN_PATH,
disabled: this.apiImpl.revokeTokenPOST === undefined,
},
{
method: "post",
pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.INTROSPECT_TOKEN_PATH),
id: constants_1.INTROSPECT_TOKEN_PATH,
disabled: this.apiImpl.introspectTokenPOST === undefined,
},
];
}
handleError(error, _, __, _userContext) {
Expand Down
33 changes: 33 additions & 0 deletions lib/build/recipe/oauth2provider/recipeImplementation.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,9 @@ function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload,
for (const key in input.body) {
body[key] = input.body[key];
}
if (input.authorizationHeader) {
body["authorizationHeader"] = input.authorizationHeader;
}
const res = await querier.sendPostRequest(
new normalisedURLPath_1.default(`/recipe/oauth2/pub/token`),
body,
Expand Down Expand Up @@ -548,6 +551,36 @@ function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload,
}
return { status: "OK" };
},
introspectToken: async function ({ token, scopes, userContext }) {
// Determine if the token is an access token by checking if it doesn't start with "ory_rt"
const isAccessToken = !token.startsWith("ory_rt");
// Attempt to validate the access token locally
// If it fails, the token is not active, and we return early
if (isAccessToken) {
try {
await this.validateOAuth2AccessToken({
token,
requirements: { scopes },
checkDatabase: false,
userContext,
});
} catch (error) {
return { active: false };
}
}
// For tokens that passed local validation or if it's a refresh token,
// validate the token with the database by calling the core introspection endpoint
const res = await querier.sendPostRequest(
new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/introspect`),
{
$isFormData: true,
token,
scope: scopes ? scopes.join(" ") : undefined,
},
userContext
);
return res.data;
},
};
}
exports.default = getRecipeInterface;
22 changes: 22 additions & 0 deletions lib/build/recipe/oauth2provider/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ export declare type UserInfo = {
phoneNumber_verified?: boolean;
[key: string]: JSONValue;
};
export declare type InstrospectTokenResponse =
| {
active: false;
}
| ({
active: true;
} & JSONObject);
export declare type RecipeInterface = {
authorization(input: {
params: Record<string, string>;
Expand All @@ -96,6 +103,7 @@ export declare type RecipeInterface = {
setCookie: string | undefined;
}>;
tokenExchange(input: {
authorizationHeader?: string;
body: Record<string, string | undefined>;
userContext: UserContext;
}): Promise<TokenInfo | ErrorOAuth2>;
Expand Down Expand Up @@ -278,6 +286,11 @@ export declare type RecipeInterface = {
}
| ErrorOAuth2
>;
introspectToken(input: {
token: string;
scopes?: string[];
userContext: UserContext;
}): Promise<InstrospectTokenResponse>;
};
export declare type APIInterface = {
loginGET:
Expand Down Expand Up @@ -312,6 +325,7 @@ export declare type APIInterface = {
tokenPOST:
| undefined
| ((input: {
authorizationHeader?: string;
body: any;
options: APIOptions;
userContext: UserContext;
Expand Down Expand Up @@ -361,6 +375,14 @@ export declare type APIInterface = {
}
| ErrorOAuth2
>);
introspectTokenPOST:
| undefined
| ((input: {
token: string;
scopes?: string[];
options: APIOptions;
userContext: UserContext;
}) => Promise<InstrospectTokenResponse | GeneralErrorResponse>);
};
export declare type OAuth2ClientOptions = {
clientId: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ async function getOpenIdDiscoveryConfiguration(apiImplementation, options, userC
token_endpoint: result.token_endpoint,
userinfo_endpoint: result.userinfo_endpoint,
revocation_endpoint: result.revocation_endpoint,
token_introspection_endpoint: result.token_introspection_endpoint,
subject_types_supported: result.subject_types_supported,
id_token_signing_alg_values_supported: result.id_token_signing_alg_values_supported,
response_types_supported: result.response_types_supported,
Expand Down
1 change: 1 addition & 0 deletions lib/build/recipe/openid/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default class OpenIdRecipeWrapper {
token_endpoint: string;
userinfo_endpoint: string;
revocation_endpoint: string;
token_introspection_endpoint: string;
subject_types_supported: string[];
id_token_signing_alg_values_supported: string[];
response_types_supported: string[];
Expand Down
1 change: 1 addition & 0 deletions lib/build/recipe/openid/recipeImplementation.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function getRecipeInterface(config, jwtRecipeImplementation, appInfo) {
token_endpoint: apiBasePath + constants_2.TOKEN_PATH,
userinfo_endpoint: apiBasePath + constants_2.USER_INFO_PATH,
revocation_endpoint: apiBasePath + constants_2.REVOKE_TOKEN_PATH,
token_introspection_endpoint: apiBasePath + constants_2.INTROSPECT_TOKEN_PATH,
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
response_types_supported: ["code", "id_token", "id_token token"],
Expand Down
2 changes: 2 additions & 0 deletions lib/build/recipe/openid/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export declare type APIInterface = {
token_endpoint: string;
userinfo_endpoint: string;
revocation_endpoint: string;
token_introspection_endpoint: string;
subject_types_supported: string[];
id_token_signing_alg_values_supported: string[];
response_types_supported: string[];
Expand All @@ -88,6 +89,7 @@ export declare type RecipeInterface = {
token_endpoint: string;
userinfo_endpoint: string;
revocation_endpoint: string;
token_introspection_endpoint: string;
subject_types_supported: string[];
id_token_signing_alg_values_supported: string[];
response_types_supported: string[];
Expand Down
1 change: 1 addition & 0 deletions lib/build/recipe/session/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export default class SessionWrapper {
token_endpoint: string;
userinfo_endpoint: string;
revocation_endpoint: string;
token_introspection_endpoint: string;
subject_types_supported: string[];
id_token_signing_alg_values_supported: string[];
response_types_supported: string[];
Expand Down
22 changes: 22 additions & 0 deletions lib/ts/framework/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,26 @@ export abstract class BaseRequest {
}
return this.parsedJSONBody;
};

getBodyAsJSONOrFormData = async (): Promise<any> => {
const contentType = this.getHeaderValue("content-type");

if (contentType) {
if (contentType.startsWith("application/json")) {
return await this.getJSONBody();
} else if (contentType.startsWith("application/x-www-form-urlencoded")) {
return await this.getFormData();
}
} else {
try {
return await this.getJSONBody();
} catch {
try {
return await this.getFormData();
} catch {
throw new Error("Unable to parse body as JSON or Form Data.");
}
}
}
};
}
8 changes: 8 additions & 0 deletions lib/ts/recipe/oauth2provider/api/implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export default function getAPIImplementation(): APIInterface {
},
tokenPOST: async (input) => {
return input.options.recipeImplementation.tokenExchange({
authorizationHeader: input.authorizationHeader,
body: input.body,
userContext: input.userContext,
});
Expand Down Expand Up @@ -99,5 +100,12 @@ export default function getAPIImplementation(): APIInterface {
});
}
},
introspectTokenPOST: async (input) => {
return input.options.recipeImplementation.introspectToken({
token: input.token,
scopes: input.scopes,
userContext: input.userContext,
});
},
};
}
Loading
Loading