Bearer auth middleware
This commit is contained in:
parent
c8f4d621a9
commit
b8b816ca9f
9 changed files with 450 additions and 2 deletions
|
|
@ -179,4 +179,13 @@ export class InvalidClientMetadataError extends OAuthError {
|
|||
constructor(message: string, errorUri?: string) {
|
||||
super("invalid_client_metadata", message, errorUri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insufficient scope error - The request requires higher privileges than provided by the access token.
|
||||
*/
|
||||
export class InsufficientScopeError extends OAuthError {
|
||||
constructor(message: string, errorUri?: string) {
|
||||
super("insufficient_scope", message, errorUri);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { OAuthRegisteredClientsStore } from '../clients.js';
|
|||
import { OAuthClientInformationFull, OAuthTokens } from '../../../shared/auth.js';
|
||||
import express, { Response } from 'express';
|
||||
import supertest from 'supertest';
|
||||
import { AuthInfo } from '../types.js';
|
||||
import { InvalidTokenError } from '../errors.js';
|
||||
|
||||
describe('Authorization Handler', () => {
|
||||
// Mock client data
|
||||
|
|
@ -72,6 +74,18 @@ describe('Authorization Handler', () => {
|
|||
};
|
||||
},
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
if (token === 'valid_token') {
|
||||
return {
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
};
|
||||
}
|
||||
throw new InvalidTokenError('Token is invalid or expired');
|
||||
},
|
||||
|
||||
async revokeToken(): Promise<void> {
|
||||
// Do nothing in mock
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { OAuthRegisteredClientsStore } from '../clients.js';
|
|||
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../shared/auth.js';
|
||||
import express, { Response } from 'express';
|
||||
import supertest from 'supertest';
|
||||
import { AuthInfo } from '../types.js';
|
||||
import { InvalidTokenError } from '../errors.js';
|
||||
|
||||
describe('Revocation Handler', () => {
|
||||
// Mock client data
|
||||
|
|
@ -53,6 +55,18 @@ describe('Revocation Handler', () => {
|
|||
};
|
||||
},
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
if (token === 'valid_token') {
|
||||
return {
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
};
|
||||
}
|
||||
throw new InvalidTokenError('Token is invalid or expired');
|
||||
},
|
||||
|
||||
async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise<void> {
|
||||
// Success - do nothing in mock
|
||||
}
|
||||
|
|
@ -86,6 +100,18 @@ describe('Revocation Handler', () => {
|
|||
expires_in: 3600,
|
||||
refresh_token: 'new_mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
if (token === 'valid_token') {
|
||||
return {
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
};
|
||||
}
|
||||
throw new InvalidTokenError('Token is invalid or expired');
|
||||
}
|
||||
// No revokeToken method
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens }
|
|||
import express, { Response } from 'express';
|
||||
import supertest from 'supertest';
|
||||
import * as pkceChallenge from 'pkce-challenge';
|
||||
import { InvalidGrantError } from '../errors.js';
|
||||
import { InvalidGrantError, InvalidTokenError } from '../errors.js';
|
||||
import { AuthInfo } from '../types.js';
|
||||
|
||||
// Mock pkce-challenge
|
||||
jest.mock('pkce-challenge', () => ({
|
||||
|
|
@ -84,6 +85,18 @@ describe('Token Handler', () => {
|
|||
throw new InvalidGrantError('The refresh token is invalid or has expired');
|
||||
},
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
if (token === 'valid_token') {
|
||||
return {
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
};
|
||||
}
|
||||
throw new InvalidTokenError('Token is invalid or expired');
|
||||
},
|
||||
|
||||
async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise<void> {
|
||||
// Do nothing in mock
|
||||
}
|
||||
|
|
|
|||
252
src/server/auth/middleware/bearerAuth.test.ts
Normal file
252
src/server/auth/middleware/bearerAuth.test.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { Request, Response } from "express";
|
||||
import { requireBearerAuth } from "./bearerAuth.js";
|
||||
import { AuthInfo } from "../types.js";
|
||||
import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js";
|
||||
import { OAuthServerProvider } from "../provider.js";
|
||||
import { OAuthRegisteredClientsStore } from "../clients.js";
|
||||
|
||||
// Mock provider
|
||||
const mockVerifyAccessToken = jest.fn();
|
||||
const mockProvider: OAuthServerProvider = {
|
||||
clientsStore: {} as OAuthRegisteredClientsStore,
|
||||
authorize: jest.fn(),
|
||||
challengeForAuthorizationCode: jest.fn(),
|
||||
exchangeAuthorizationCode: jest.fn(),
|
||||
exchangeRefreshToken: jest.fn(),
|
||||
verifyAccessToken: mockVerifyAccessToken,
|
||||
};
|
||||
|
||||
describe("requireBearerAuth middleware", () => {
|
||||
let mockRequest: Partial<Request>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let nextFunction: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
headers: {},
|
||||
};
|
||||
mockResponse = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
};
|
||||
nextFunction = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should call next when token is valid", async () => {
|
||||
const validAuthInfo: AuthInfo = {
|
||||
token: "valid-token",
|
||||
clientId: "client-123",
|
||||
scopes: ["read", "write"],
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(validAuthInfo);
|
||||
|
||||
mockRequest.headers = {
|
||||
authorization: "Bearer valid-token",
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
|
||||
expect(mockRequest.auth).toEqual(validAuthInfo);
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
expect(mockResponse.status).not.toHaveBeenCalled();
|
||||
expect(mockResponse.json).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should require specific scopes when configured", async () => {
|
||||
const authInfo: AuthInfo = {
|
||||
token: "valid-token",
|
||||
clientId: "client-123",
|
||||
scopes: ["read"],
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(authInfo);
|
||||
|
||||
mockRequest.headers = {
|
||||
authorization: "Bearer valid-token",
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({
|
||||
provider: mockProvider,
|
||||
requiredScopes: ["read", "write"]
|
||||
});
|
||||
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(403);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
"WWW-Authenticate",
|
||||
expect.stringContaining('Bearer error="insufficient_scope"')
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: "insufficient_scope", error_description: "Insufficient scope" })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should accept token with all required scopes", async () => {
|
||||
const authInfo: AuthInfo = {
|
||||
token: "valid-token",
|
||||
clientId: "client-123",
|
||||
scopes: ["read", "write", "admin"],
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(authInfo);
|
||||
|
||||
mockRequest.headers = {
|
||||
authorization: "Bearer valid-token",
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({
|
||||
provider: mockProvider,
|
||||
requiredScopes: ["read", "write"]
|
||||
});
|
||||
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
|
||||
expect(mockRequest.auth).toEqual(authInfo);
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
expect(mockResponse.status).not.toHaveBeenCalled();
|
||||
expect(mockResponse.json).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return 401 when no Authorization header is present", async () => {
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).not.toHaveBeenCalled();
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
"WWW-Authenticate",
|
||||
expect.stringContaining('Bearer error="invalid_token"')
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: "invalid_token", error_description: "Missing Authorization header" })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return 401 when Authorization header format is invalid", async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: "InvalidFormat",
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).not.toHaveBeenCalled();
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
"WWW-Authenticate",
|
||||
expect.stringContaining('Bearer error="invalid_token"')
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: "invalid_token",
|
||||
error_description: "Invalid Authorization header format, expected 'Bearer TOKEN'"
|
||||
})
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return 401 when token verification fails with InvalidTokenError", async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: "Bearer invalid-token",
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired"));
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token");
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
"WWW-Authenticate",
|
||||
expect.stringContaining('Bearer error="invalid_token"')
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: "invalid_token", error_description: "Token expired" })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return 403 when access token has insufficient scopes", async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: "Bearer valid-token",
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: read, write"));
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(403);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
"WWW-Authenticate",
|
||||
expect.stringContaining('Bearer error="insufficient_scope"')
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: "insufficient_scope", error_description: "Required scopes: read, write" })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return 500 when a ServerError occurs", async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: "Bearer valid-token",
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue"));
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: "server_error", error_description: "Internal server issue" })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return 400 for generic OAuthError", async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: "Bearer valid-token",
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new OAuthError("custom_error", "Some OAuth error"));
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(400);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: "custom_error", error_description: "Some OAuth error" })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return 500 when unexpected error occurs", async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: "Bearer valid-token",
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new Error("Unexpected error"));
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: "server_error", error_description: "Internal Server Error" })
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
78
src/server/auth/middleware/bearerAuth.ts
Normal file
78
src/server/auth/middleware/bearerAuth.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { RequestHandler } from "express";
|
||||
import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js";
|
||||
import { OAuthServerProvider } from "../provider.js";
|
||||
import { AuthInfo } from "../types.js";
|
||||
|
||||
export type BearerAuthMiddlewareOptions = {
|
||||
/**
|
||||
* A provider used to verify tokens.
|
||||
*/
|
||||
provider: OAuthServerProvider;
|
||||
|
||||
/**
|
||||
* Optional scopes that the token must have.
|
||||
*/
|
||||
requiredScopes?: string[];
|
||||
};
|
||||
|
||||
declare module "express-serve-static-core" {
|
||||
interface Request {
|
||||
/**
|
||||
* Information about the validated access token, if the `requireBearerAuth` middleware was used.
|
||||
*/
|
||||
auth?: AuthInfo;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that requires a valid Bearer token in the Authorization header.
|
||||
*
|
||||
* This will validate the token with the auth provider and add the resulting auth info to the request object.
|
||||
*/
|
||||
export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthMiddlewareOptions): RequestHandler {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) {
|
||||
throw new InvalidTokenError("Missing Authorization header");
|
||||
}
|
||||
|
||||
const [type, token] = authHeader.split(' ');
|
||||
if (type.toLowerCase() !== 'bearer' || !token) {
|
||||
throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'");
|
||||
}
|
||||
|
||||
const authInfo = await provider.verifyAccessToken(token);
|
||||
|
||||
// Check if token has the required scopes (if any)
|
||||
if (requiredScopes.length > 0) {
|
||||
const hasAllScopes = requiredScopes.every(scope =>
|
||||
authInfo.scopes.includes(scope)
|
||||
);
|
||||
|
||||
if (!hasAllScopes) {
|
||||
throw new InsufficientScopeError("Insufficient scope");
|
||||
}
|
||||
}
|
||||
|
||||
req.auth = authInfo;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidTokenError) {
|
||||
res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`);
|
||||
res.status(401).json(error.toResponseObject());
|
||||
} else if (error instanceof InsufficientScopeError) {
|
||||
res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`);
|
||||
res.status(403).json(error.toResponseObject());
|
||||
} else if (error instanceof ServerError) {
|
||||
res.status(500).json(error.toResponseObject());
|
||||
} else if (error instanceof OAuthError) {
|
||||
res.status(400).json(error.toResponseObject());
|
||||
} else {
|
||||
console.error("Unexpected error authenticating bearer token:", error);
|
||||
const serverError = new ServerError("Internal Server Error");
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Response } from "express";
|
||||
import { OAuthRegisteredClientsStore } from "./clients.js";
|
||||
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from "../../shared/auth.js";
|
||||
import { AuthInfo } from "./types.js";
|
||||
|
||||
export type AuthorizationParams = {
|
||||
state?: string;
|
||||
|
|
@ -42,6 +43,11 @@ export interface OAuthServerProvider {
|
|||
*/
|
||||
exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens>;
|
||||
|
||||
/**
|
||||
* Verifies an access token and returns information about it.
|
||||
*/
|
||||
verifyAccessToken(token: string): Promise<AuthInfo>;
|
||||
|
||||
/**
|
||||
* Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { OAuthRegisteredClientsStore } from './clients.js';
|
|||
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js';
|
||||
import express, { Response } from 'express';
|
||||
import supertest from 'supertest';
|
||||
import { AuthInfo } from './types.js';
|
||||
import { InvalidTokenError } from './errors.js';
|
||||
|
||||
describe('MCP Auth Router', () => {
|
||||
// Setup mock provider with full capabilities
|
||||
|
|
@ -58,6 +60,18 @@ describe('MCP Auth Router', () => {
|
|||
};
|
||||
},
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
if (token === 'valid_token') {
|
||||
return {
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
};
|
||||
}
|
||||
throw new InvalidTokenError('Token is invalid or expired');
|
||||
},
|
||||
|
||||
async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise<void> {
|
||||
// Success - do nothing in mock
|
||||
}
|
||||
|
|
@ -107,6 +121,18 @@ describe('MCP Auth Router', () => {
|
|||
expires_in: 3600,
|
||||
refresh_token: 'new_mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
if (token === 'valid_token') {
|
||||
return {
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
};
|
||||
}
|
||||
throw new InvalidTokenError('Token is invalid or expired');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
24
src/server/auth/types.ts
Normal file
24
src/server/auth/types.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Information about a validated access token, provided to request handlers.
|
||||
*/
|
||||
export interface AuthInfo {
|
||||
/**
|
||||
* The access token.
|
||||
*/
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* The client ID associated with this token.
|
||||
*/
|
||||
clientId: string;
|
||||
|
||||
/**
|
||||
* Scopes associated with this token.
|
||||
*/
|
||||
scopes: string[];
|
||||
|
||||
/**
|
||||
* When the token expires (in seconds since epoch).
|
||||
*/
|
||||
expiresAt?: number;
|
||||
}
|
||||
Loading…
Reference in a new issue