Skip to content

Commit

Permalink
feat: Add token introspection endpoint (#906)
Browse files Browse the repository at this point in the history
* feat: Add token revocation endpoint

* fix: PR changes

* fix: PR changes

* fix: PR changes

* fix: PR changes

* feat: Add token introspection endpoint

* fix: PR changes

* fix: Add revocation_endpoint

* fix: PR changes

* fix: merge issue

---------

Co-authored-by: Mihaly Lengyel <mihaly@lengyel.tech>
  • Loading branch information
anku255 and porcellus authored Aug 8, 2024
1 parent a7a2b87 commit 611d860
Show file tree
Hide file tree
Showing 33 changed files with 342 additions and 7 deletions.
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

0 comments on commit 611d860

Please sign in to comment.