Skip to content

Commit

Permalink
Support wildcard scopes in M2M auth (#320)
Browse files Browse the repository at this point in the history
  • Loading branch information
logan-stytch authored Jun 11, 2024
1 parent 4badea0 commit 09c32b6
Show file tree
Hide file tree
Showing 10 changed files with 223 additions and 25 deletions.
22 changes: 15 additions & 7 deletions dist/b2c/m2m.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions dist/b2c/m2m_local.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 17 additions & 11 deletions lib/b2c/m2m.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import * as jose from "jose";
import {} from "../shared/method_options";
import { Clients } from "./m2m_clients";
import { fetchConfig } from "../shared";
import { performAuthorizationCheck, ScopeAuthorizationFunc } from "./m2m_local";

import { authenticateM2MJwtLocal, JwtConfig } from "../shared/sessions";
import { ClientError } from "../shared/errors";
import { request } from "../shared";
import { ClientError } from "../shared/errors";

export interface M2MClient {
// The ID of the client.
Expand Down Expand Up @@ -235,21 +236,26 @@ export class M2M {

// MANUAL(authenticateToken)(SERVICE_METHOD)
// ADDIMPORT: import { authenticateM2MJwtLocal, JwtConfig } from "../shared/sessions";
// ADDIMPORT: import { ClientError } from "../shared/errors";
// ADDIMPORT: import { request } from "../shared";
// I do not know why, but it only works if we add the ADDIMPORT here, not on the ^ manual section
// ADDIMPORT: import { performAuthorizationCheck, ScopeAuthorizationFunc } from "./m2m_local";
// ADDIMPORT: import { ClientError } from "../shared/errors";
/**
* Authenticate an access token issued by Stytch from the Token endpoint.
* M2M access tokens are JWTs signed with the project's JWKs, and can be validated locally using any Stytch client library.
* You may pass in an optional set of scopes that the JWT must contain in order to enforce permissions.
* You may also override the default scope authorization function to implement custom authorization logic.
*
* @param data {@link AuthenticateTokenRequest}
* @param scopeAuthorizationFunc {@link ScopeAuthorizationFunc} - A function that checks if the token has the required scopes.
The default function assumes scopes are either direct string matches or written in the form "action:resource". See the
documentation for {@link performAuthorizationCheck} for more information.
* @async
* @returns {@link AuthenticateTokenResponse}
* @throws {ClientError} when token can not be authenticated
*/
async authenticateToken(
data: AuthenticateTokenRequest
data: AuthenticateTokenRequest,
scopeAuthorizationFunc: ScopeAuthorizationFunc = performAuthorizationCheck
): Promise<AuthenticateTokenResponse> {
const { sub, scope, custom_claims } = await authenticateM2MJwtLocal(
this.jwksClient,
Expand All @@ -260,15 +266,15 @@ export class M2M {
const scopes = scope.split(" ");

if (data.required_scopes && data.required_scopes.length > 0) {
const missingScopes = data.required_scopes.filter(
(scope) => !scopes.includes(scope)
);

if (missingScopes.length > 0) {
const isAuthorized = scopeAuthorizationFunc({
hasScopes: scopes,
requiredScopes: data.required_scopes,
});
if (!isAuthorized) {
throw new ClientError(
"missing_scopes",
"Missing required scopes",
missingScopes
"Missing at least one required scope",
data.required_scopes
);
}
}
Expand Down
45 changes: 45 additions & 0 deletions lib/b2c/m2m_local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export type ScopeAuthorizationFunc = ({
hasScopes,
requiredScopes,
}: {
hasScopes: string[];
requiredScopes: string[];
}) => boolean;

export function performAuthorizationCheck({
hasScopes,
requiredScopes,
}: {
hasScopes: string[];
requiredScopes: string[];
}): boolean {
const clientScopes: { [key: string]: Set<string> } = {};
hasScopes.forEach((scope) => {
let action = scope;
let resource = "-";
if (scope.includes(":")) {
[action, resource] = scope.split(":");
}
if (!clientScopes[action]) {
clientScopes[action] = new Set();
}
clientScopes[action].add(resource);
});

for (const requiredScope of requiredScopes) {
let requiredAction = requiredScope;
let requiredResource = "-";
if (requiredScope.includes(":")) {
[requiredAction, requiredResource] = requiredScope.split(":");
}
if (!clientScopes[requiredAction]) {
return false;
}
const resources = clientScopes[requiredAction];
// The client can either have a wildcard resource or the specific resource
if (!resources.has("*") && !resources.has(requiredResource)) {
return false;
}
}
return true;
}
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "stytch",
"version": "10.17.0",
"version": "10.18.0",
"description": "A wrapper for the Stytch API",
"types": "./types/lib/index.d.ts",
"main": "./dist/index.js",
Expand Down
93 changes: 90 additions & 3 deletions test/b2c/m2m.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { MOCK_FETCH_CONFIG } from "../helpers";
import { request } from "../../lib/shared";
import { M2M } from "../../lib/b2c/m2m";
import * as jose from "jose";
import { performAuthorizationCheck } from "../../lib/b2c/m2m_local";
import { ClientError } from "../../lib/shared/errors";

jest.mock("../../lib/shared");

Expand Down Expand Up @@ -142,9 +144,7 @@ describe("m2m.authenticateToken", () => {
access_token: accessToken,
required_scopes: ["write:giraffes"],
})
).rejects.toThrow(
"missing_scopes: Missing required scopes: write:giraffes"
);
).rejects.toThrow(ClientError);
});

it("fails when stale", async () => {
Expand All @@ -156,3 +156,90 @@ describe("m2m.authenticateToken", () => {
).rejects.toThrow(/jwt_too_old/);
});
});

describe("performAuthorizationCheck", () => {
test("basic", () => {
const has = ["read:users", "write:users"];
const needs = ["read:users"];

const res = performAuthorizationCheck({
hasScopes: has,
requiredScopes: needs,
});
expect(res).toEqual(true);
});

test("multiple required scopes", () => {
const has = ["read:users", "write:users", "read:books"];
const needs = ["read:users", "read:books"];

const res = performAuthorizationCheck({
hasScopes: has,
requiredScopes: needs,
});
expect(res).toEqual(true);
});

test("simple scopes", () => {
const has = ["read_users", "write_users"];
const needs = ["read_users"];

const res = performAuthorizationCheck({
hasScopes: has,
requiredScopes: needs,
});
expect(res).toEqual(true);
});

test("wildcard resource", () => {
const has = ["read:*", "write:*"];
const needs = ["read:users"];

const res = performAuthorizationCheck({
hasScopes: has,
requiredScopes: needs,
});
expect(res).toEqual(true);
});

test("missing required scope", () => {
const has = ["read:users"];
const needs = ["write:users"];

const res = performAuthorizationCheck({
hasScopes: has,
requiredScopes: needs,
});
expect(res).toEqual(false);
});

test("missing required scope with wildcard", () => {
const has = ["read:users", "write:*"];
const needs = ["delete:books"];

const res = performAuthorizationCheck({
hasScopes: has,
requiredScopes: needs,
});
expect(res).toEqual(false);
});
test("has simple scope and wants specific scope", () => {
const params = {
hasScopes: ["read"],
requiredScopes: ["read:users"],
};

const res = performAuthorizationCheck(params);
expect(res).toEqual(false);
});

test("has specific scope and wants simple scope", () => {
const params = {
hasScopes: ["read:users"],
requiredScopes: ["read"],
};

const res = performAuthorizationCheck(params);
expect(res).toEqual(false);
});
});
2 changes: 1 addition & 1 deletion test/b2c/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ describeIf(
access_token,
required_scopes: ["pet:dogs"],
})
).rejects.toThrow("Missing required scopes");
).rejects.toThrow("missing_scopes");
});
});
}
Expand Down
7 changes: 6 additions & 1 deletion types/lib/b2c/m2m.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions types/lib/b2c/m2m_local.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 09c32b6

Please sign in to comment.