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

Support wildcard scopes in M2M auth #320

Merged
merged 2 commits into from
Jun 11, 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
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.

Loading