diff --git a/examples/client-credentials-example.ts b/examples/client-credentials-example.ts new file mode 100644 index 00000000..09a40b58 --- /dev/null +++ b/examples/client-credentials-example.ts @@ -0,0 +1,115 @@ +import { issuer } from "@openauthjs/openauth" +import { ClientCredentialsProvider } from "@openauthjs/openauth/provider/client-credentials" +import { MemoryStorage } from "@openauthjs/openauth/storage/memory" +import { createSubjects } from "@openauthjs/openauth/subject" +import { object, string, optional, array } from "valibot" + +// Define subjects for machine-to-machine authentication +const subjects = createSubjects({ + service: object({ + serviceID: string(), + scopes: optional(array(string())), + tier: optional(string()), + }), +}) + +// Mock database for this example +const serviceDatabase = { + "api-service-1": { + hashedSecret: "hashed-secret-1", // In production, use bcrypt or similar + plainSecret: "secret-1", // Only for demo + allowedScopes: ["read:users", "read:posts"], + tier: "basic", + name: "API Service 1", + }, + "api-service-2": { + hashedSecret: "hashed-secret-2", + plainSecret: "secret-2", + allowedScopes: ["read:users", "write:users", "read:posts", "write:posts"], + tier: "premium", + name: "API Service 2", + }, +} + +// Create the issuer with client credentials provider +const app = issuer({ + subjects, + storage: MemoryStorage(), + providers: { + clientCredentials: ClientCredentialsProvider({ + async verify(clientID, clientSecret, requestedScopes) { + // Look up the service in database + const service = + serviceDatabase[clientID as keyof typeof serviceDatabase] + + if (!service) { + throw new Error("Invalid client_id") + } + + // In production, use proper password hashing comparison + // For example: await bcrypt.compare(clientSecret, service.hashedSecret) + if (clientSecret !== service.plainSecret) { + throw new Error("Invalid client_secret") + } + + // Validate requested scopes if any + if (requestedScopes && requestedScopes.length > 0) { + const invalidScopes = requestedScopes.filter( + (scope) => !service.allowedScopes.includes(scope), + ) + + if (invalidScopes.length > 0) { + throw new Error( + `Invalid scopes requested: ${invalidScopes.join(", ")}`, + ) + } + + // Return only the requested scopes + return { + scopes: requestedScopes, + properties: { + tier: service.tier, + name: service.name, + }, + } + } + + // Return all allowed scopes if none specifically requested + return { + scopes: service.allowedScopes, + properties: { + tier: service.tier, + name: service.name, + }, + } + }, + }), + }, + async success(ctx, value) { + if (value.provider === "clientCredentials") { + // For machine-to-machine auth, use the clientID as the serviceID + return ctx.subject("service", { + serviceID: value.clientID, + scopes: value.scopes, + tier: value.properties?.tier, + }) + } + throw new Error("Unknown provider") + }, +}) + +// Example usage: +// To authenticate, make a POST request to /token with: +// - grant_type: "client_credentials" +// - provider: "clientCredentials" +// - client_id: "api-service-1" +// - client_secret: "secret-1" +// - scope: "read:users read:posts" (optional) +// +// Response will include: +// - access_token: JWT access token +// - token_type: "Bearer" +// - expires_in: Token lifetime in seconds +// Note: No refresh token is provided for client credentials flow + +export default app diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts index f4c1f277..ee01f2df 100644 --- a/packages/openauth/src/issuer.ts +++ b/packages/openauth/src/issuer.ts @@ -776,12 +776,24 @@ export function issuer< }), async (c) => { const iss = issuer(c) + + // Check if any provider supports client credentials + const supportsClientCredentials = Object.values(input.providers).some( + (provider) => provider.client !== undefined, + ) + + const grantTypes = ["authorization_code", "refresh_token"] + if (supportsClientCredentials) { + grantTypes.push("client_credentials") + } + return c.json({ issuer: iss, authorization_endpoint: `${iss}/authorize`, token_endpoint: `${iss}/token`, jwks_uri: `${iss}/.well-known/jwks.json`, response_types_supported: ["code", "token"], + grant_types_supported: grantTypes, }) }, ) @@ -881,6 +893,7 @@ export function issuer< await Storage.remove(storage, key) return c.json({ access_token: tokens.access, + token_type: "Bearer", expires_in: tokens.expiresIn, refresh_token: tokens.refresh, }) @@ -949,23 +962,35 @@ export function issuer< }) return c.json({ access_token: tokens.access, + token_type: "Bearer", refresh_token: tokens.refresh, expires_in: tokens.expiresIn, }) } if (grantType === "client_credentials") { - const provider = form.get("provider") - if (!provider) - return c.json({ error: "missing `provider` form value" }, 400) - const match = input.providers[provider.toString()] - if (!match) - return c.json({ error: "invalid `provider` query parameter" }, 400) - if (!match.client) + // Auto-detect provider that supports client credentials + const clientCredentialsProviders = Object.entries( + input.providers, + ).filter(([_, p]) => p.client) + + if (clientCredentialsProviders.length === 0) { + return c.json( + { error: "no providers support client_credentials" }, + 400, + ) + } + + // Use the first provider that supports client credentials + const [selectedProvider, match] = clientCredentialsProviders[0] + + if (!match || !match.client) { return c.json( - { error: "this provider does not support client_credentials" }, + { error: "no valid provider found for client_credentials" }, 400, ) + } + const clientID = form.get("client_id") const clientSecret = form.get("client_secret") if (!clientID) @@ -980,25 +1005,30 @@ export function issuer< return input.success( { async subject(type, properties, opts) { - const tokens = await generateTokens(c, { - type: type as string, - subject: - opts?.subject || (await resolveSubject(type, properties)), - properties, - clientID: clientID.toString(), - ttl: { - access: opts?.ttl?.access ?? ttlAccess, - refresh: opts?.ttl?.refresh ?? ttlRefresh, + const tokens = await generateTokens( + c, + { + type: type as string, + subject: + opts?.subject || (await resolveSubject(type, properties)), + properties, + clientID: clientID.toString(), + ttl: { + access: opts?.ttl?.access ?? ttlAccess, + refresh: opts?.ttl?.refresh ?? ttlRefresh, + }, }, - }) + { generateRefreshToken: false }, + ) return c.json({ access_token: tokens.access, - refresh_token: tokens.refresh, + token_type: "Bearer", + expires_in: tokens.expiresIn, }) }, }, { - provider: provider.toString(), + provider: selectedProvider, ...response, }, c.req.raw, diff --git a/packages/openauth/src/provider/client-credentials.ts b/packages/openauth/src/provider/client-credentials.ts new file mode 100644 index 00000000..db1b2219 --- /dev/null +++ b/packages/openauth/src/provider/client-credentials.ts @@ -0,0 +1,132 @@ +/** + * Use this provider to authenticate machine-to-machine applications using client credentials. + * + * ```ts {5-18} + * import { ClientCredentialsProvider } from "@openauthjs/openauth/provider/client-credentials" + * + * export default issuer({ + * providers: { + * clientCredentials: ClientCredentialsProvider({ + * async verify(clientID, clientSecret, scopes) { + * // Look up client in database + * const client = await db.getClient(clientID) + * if (!client || client.secret !== clientSecret) { + * throw new Error("Invalid client credentials") + * } + * // Verify scopes if requested + * // Return any properties to include in the token + * return { + * scopes: client.allowedScopes, + * properties: { tier: client.tier } + * } + * } + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Provider } from "./provider.js" + +export interface ClientCredentialsConfig { + /** + * An async function to verify client credentials and return allowed scopes and properties. + * + * @param clientID - The client ID to verify + * @param clientSecret - The client secret to verify + * @param requestedScopes - The scopes requested by the client (if any) + * @returns The allowed scopes and any additional properties to include in the token + * @throws Error if the credentials are invalid + * + * @example + * ```ts + * { + * async verify(clientID, clientSecret, requestedScopes) { + * const client = await db.getClient(clientID) + * if (!client || !await bcrypt.compare(clientSecret, client.hashedSecret)) { + * throw new Error("Invalid client credentials") + * } + * + * // Optionally validate requested scopes against allowed scopes + * if (requestedScopes?.length > 0) { + * const invalidScopes = requestedScopes.filter(s => !client.allowedScopes.includes(s)) + * if (invalidScopes.length > 0) { + * throw new Error(`Invalid scopes: ${invalidScopes.join(", ")}`) + * } + * return { scopes: requestedScopes } + * } + * + * return { + * scopes: client.allowedScopes, + * properties: { tier: client.tier, name: client.name } + * } + * } + * } + * ``` + */ + verify: ( + clientID: string, + clientSecret: string, + requestedScopes?: string[], + ) => Promise<{ + scopes?: string[] + properties?: Record + }> +} + +/** + * Create a Client Credentials provider for machine-to-machine authentication. + * + * @param config - The config for the provider. + * @example + * ```ts + * ClientCredentialsProvider({ + * async verify(clientID, clientSecret, scopes) { + * const client = await db.getClient(clientID) + * if (!client || client.secret !== clientSecret) { + * throw new Error("Invalid client credentials") + * } + * return { scopes: client.allowedScopes } + * } + * }) + * ``` + */ +export function ClientCredentialsProvider( + config: ClientCredentialsConfig, +): Provider<{ + clientID: string + scopes?: string[] + properties?: Record +}> { + return { + type: "client_credentials", + init() { + // Client credentials flow doesn't need any routes since it only uses the /token endpoint + }, + async client(input) { + try { + // Parse requested scopes from the request + const requestedScopes = + input.params.scope?.split(" ").filter(Boolean) || [] + + // Call the verify function + const result = await config.verify( + input.clientID, + input.clientSecret, + requestedScopes.length > 0 ? requestedScopes : undefined, + ) + + return { + clientID: input.clientID, + scopes: result.scopes, + properties: result.properties || {}, + } + } catch (error) { + // Re-throw the error from verify function + throw error + } + }, + } +} diff --git a/packages/openauth/test/client-credentials.test.ts b/packages/openauth/test/client-credentials.test.ts new file mode 100644 index 00000000..1ffdd7cb --- /dev/null +++ b/packages/openauth/test/client-credentials.test.ts @@ -0,0 +1,372 @@ +import { expect, test, describe } from "bun:test" +import { object, string, array, optional } from "valibot" +import { issuer } from "../src/issuer.js" +import { createSubjects } from "../src/subject.js" +import { MemoryStorage } from "../src/storage/memory.js" +import { ClientCredentialsProvider } from "../src/provider/client-credentials.js" + +const subjects = createSubjects({ + service: object({ + serviceID: string(), + scopes: optional(array(string())), + tier: optional(string()), + }), +}) + +describe("ClientCredentialsProvider", () => { + // Mock client database + const mockClients = { + "service-a": { + secret: "secret-a", + allowedScopes: ["read", "write"], + tier: "premium", + }, + "service-b": { + secret: "secret-b", + allowedScopes: ["read"], + tier: "basic", + }, + } + + test("successful authentication", async () => { + const app = issuer({ + storage: MemoryStorage(), + subjects, + providers: { + clientCredentials: ClientCredentialsProvider({ + async verify(clientID, clientSecret) { + const client = mockClients[clientID as keyof typeof mockClients] + if (!client || client.secret !== clientSecret) { + throw new Error("Invalid client credentials") + } + return { + scopes: client.allowedScopes, + properties: { tier: client.tier }, + } + }, + }), + }, + async success(ctx, value) { + return ctx.subject("service", { + serviceID: value.clientID, + scopes: value.scopes, + }) + }, + }) + + const response = await app.request("/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "client_credentials", + provider: "clientCredentials", + client_id: "service-a", + client_secret: "secret-a", + }), + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toHaveProperty("access_token") + expect(body).not.toHaveProperty("refresh_token") + }) + + test("authentication with specific scopes", async () => { + const app = issuer({ + storage: MemoryStorage(), + subjects, + providers: { + clientCredentials: ClientCredentialsProvider({ + async verify(clientID, clientSecret, requestedScopes) { + const client = mockClients[clientID as keyof typeof mockClients] + if (!client || client.secret !== clientSecret) { + throw new Error("Invalid client credentials") + } + + // For this test, service-a can also have admin scope + const extendedScopes = + clientID === "service-a" + ? [...client.allowedScopes, "admin"] + : client.allowedScopes + + // Validate requested scopes + if (requestedScopes && requestedScopes.length > 0) { + const invalidScopes = requestedScopes.filter( + (scope) => !extendedScopes.includes(scope), + ) + if (invalidScopes.length > 0) { + throw new Error(`Invalid scopes: ${invalidScopes.join(", ")}`) + } + return { scopes: requestedScopes } + } + + return { scopes: extendedScopes } + }, + }), + }, + async success(ctx, value) { + return ctx.subject("service", { + serviceID: value.clientID, + scopes: value.scopes, + }) + }, + }) + + const response = await app.request("/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "client_credentials", + provider: "clientCredentials", + client_id: "service-a", + client_secret: "secret-a", + scope: "read write", + }), + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toHaveProperty("access_token") + }) + + test("invalid client_id", async () => { + const app = issuer({ + storage: MemoryStorage(), + subjects, + providers: { + clientCredentials: ClientCredentialsProvider({ + async verify(clientID, clientSecret) { + const client = mockClients[clientID as keyof typeof mockClients] + if (!client || client.secret !== clientSecret) { + throw new Error("Invalid client credentials") + } + return { scopes: client.allowedScopes } + }, + }), + }, + async success(ctx, value) { + return ctx.subject("service", { + serviceID: value.clientID, + }) + }, + }) + + const response = await app.request("/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "client_credentials", + provider: "clientCredentials", + client_id: "invalid-service", + client_secret: "secret-a", + }), + }) + + expect(response.status).toBe(400) + }) + + test("invalid client_secret", async () => { + const app = issuer({ + storage: MemoryStorage(), + subjects, + providers: { + clientCredentials: ClientCredentialsProvider({ + async verify(clientID, clientSecret) { + const client = mockClients[clientID as keyof typeof mockClients] + if (!client || client.secret !== clientSecret) { + throw new Error("Invalid client credentials") + } + return { scopes: client.allowedScopes } + }, + }), + }, + async success(ctx, value) { + return ctx.subject("service", { + serviceID: value.clientID, + }) + }, + }) + + const response = await app.request("/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "client_credentials", + provider: "clientCredentials", + client_id: "service-a", + client_secret: "wrong-secret", + }), + }) + + expect(response.status).toBe(400) + }) + + test("invalid scopes", async () => { + const app = issuer({ + storage: MemoryStorage(), + subjects, + providers: { + clientCredentials: ClientCredentialsProvider({ + async verify(clientID, clientSecret, requestedScopes) { + const client = mockClients[clientID as keyof typeof mockClients] + if (!client || client.secret !== clientSecret) { + throw new Error("Invalid client credentials") + } + + // Validate requested scopes strictly + if (requestedScopes && requestedScopes.length > 0) { + const invalidScopes = requestedScopes.filter( + (scope) => !client.allowedScopes.includes(scope), + ) + if (invalidScopes.length > 0) { + throw new Error(`Invalid scopes: ${invalidScopes.join(", ")}`) + } + return { scopes: requestedScopes } + } + + return { scopes: client.allowedScopes } + }, + }), + }, + async success(ctx, value) { + return ctx.subject("service", { + serviceID: value.clientID, + scopes: value.scopes, + }) + }, + }) + + const response = await app.request("/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "client_credentials", + provider: "clientCredentials", + client_id: "service-a", + client_secret: "secret-a", + scope: "read write admin", // 'admin' is not allowed + }), + }) + + expect(response.status).toBe(400) + }) + + test("async verify function error handling", async () => { + const app = issuer({ + storage: MemoryStorage(), + subjects, + providers: { + clientCredentials: ClientCredentialsProvider({ + async verify() { + // Simulate a database error + throw new Error("Database connection failed") + }, + }), + }, + async success(ctx, value) { + return ctx.subject("service", { + serviceID: value.clientID, + }) + }, + }) + + const response = await app.request("/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "client_credentials", + provider: "clientCredentials", + client_id: "service-a", + client_secret: "secret-a", + }), + }) + + expect(response.status).toBe(400) + }) + + test("verify function returns custom properties", async () => { + const app = issuer({ + storage: MemoryStorage(), + subjects, + providers: { + clientCredentials: ClientCredentialsProvider({ + async verify(clientID) { + return { + scopes: ["read", "write"], + properties: { + tier: "premium", + region: "us-east-1", + customField: clientID, + }, + } + }, + }), + }, + async success(ctx, value) { + // Verify we receive the custom properties + expect(value.properties).toHaveProperty("tier", "premium") + expect(value.properties).toHaveProperty("region", "us-east-1") + expect(value.properties).toHaveProperty("customField", value.clientID) + + return ctx.subject("service", { + serviceID: value.clientID, + scopes: value.scopes, + }) + }, + }) + + const response = await app.request("/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "client_credentials", + provider: "clientCredentials", + client_id: "service-a", + client_secret: "secret-a", + }), + }) + + expect(response.status).toBe(200) + }) + + test("response includes expires_in field", async () => { + const app = issuer({ + storage: MemoryStorage(), + subjects, + providers: { + clientCredentials: ClientCredentialsProvider({ + async verify(clientID, clientSecret) { + if (clientID === "service-test" && clientSecret === "secret-test") { + return { + scopes: ["read", "write"], + properties: { tier: "premium" }, + } + } + throw new Error("Invalid credentials") + }, + }), + }, + async success(ctx, value) { + return ctx.subject("service", { + serviceID: value.clientID, + scopes: value.scopes, + tier: value.properties?.tier, + }) + }, + }) + + const response = await app.request("/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "client_credentials", + provider: "clientCredentials", + client_id: "service-test", + client_secret: "secret-test", + }), + }) + + expect(response.status).toBe(200) + const body = await response.json() + + // Verify response structure + expect(body).toHaveProperty("access_token") + expect(body).not.toHaveProperty("refresh_token") + expect(body).toHaveProperty("expires_in") + expect(body).toHaveProperty("token_type", "Bearer") + expect(typeof body.expires_in).toBe("number") + expect(body.expires_in).toBeGreaterThan(0) + }) +}) diff --git a/packages/openauth/test/issuer.test.ts b/packages/openauth/test/issuer.test.ts index be303d77..535c5e93 100644 --- a/packages/openauth/test/issuer.test.ts +++ b/packages/openauth/test/issuer.test.ts @@ -142,7 +142,8 @@ describe("client credentials flow", () => { const tokens = await response.json() expect(tokens).toStrictEqual({ access_token: expectNonEmptyString, - refresh_token: expectNonEmptyString, + token_type: "Bearer", + expires_in: expect.any(Number), }) const verified = await client.verify(subjects, tokens.access_token) expect(verified).toStrictEqual({ @@ -221,8 +222,9 @@ describe("refresh token", () => { const refreshed = await response.json() expect(refreshed).toStrictEqual({ access_token: expectNonEmptyString, - refresh_token: expectNonEmptyString, + token_type: "Bearer", expires_in: expect.any(Number), + refresh_token: expectNonEmptyString, }) expect(refreshed.access_token).not.toEqual(tokens.access) expect(refreshed.refresh_token).not.toEqual(tokens.refresh) @@ -247,8 +249,9 @@ describe("refresh token", () => { const refreshed = await response.json() expect(refreshed).toStrictEqual({ access_token: expectNonEmptyString, - refresh_token: expectNonEmptyString, + token_type: "Bearer", expires_in: expect.any(Number), + refresh_token: expectNonEmptyString, }) expect(refreshed.access_token).not.toEqual(tokens.access) diff --git a/packages/openauth/test/well-known.test.ts b/packages/openauth/test/well-known.test.ts new file mode 100644 index 00000000..57137147 --- /dev/null +++ b/packages/openauth/test/well-known.test.ts @@ -0,0 +1,106 @@ +import { expect, test, describe } from "bun:test" +import { object, string } from "valibot" +import { issuer } from "../src/issuer.js" +import { createSubjects } from "../src/subject.js" +import { MemoryStorage } from "../src/storage/memory.js" +import { ClientCredentialsProvider } from "../src/provider/client-credentials.js" +import { PasswordProvider } from "../src/provider/password.js" + +const subjects = createSubjects({ + user: object({ + userID: string(), + }), +}) + +describe("Well-known endpoints", () => { + test("includes client_credentials in grant_types_supported when provider supports it", async () => { + const app = issuer({ + storage: MemoryStorage(), + subjects, + providers: { + clientCredentials: ClientCredentialsProvider({ + async verify() { + return { scopes: ["read"] } + }, + }), + }, + async success(ctx, value) { + return ctx.subject("user", { userID: "123" }) + }, + }) + + const response = await app.request( + "/.well-known/oauth-authorization-server", + ) + expect(response.status).toBe(200) + + const body = await response.json() + expect(body.grant_types_supported).toContain("client_credentials") + expect(body.grant_types_supported).toContain("authorization_code") + expect(body.grant_types_supported).toContain("refresh_token") + }) + + test("excludes client_credentials when no provider supports it", async () => { + const app = issuer({ + storage: MemoryStorage(), + subjects, + providers: { + password: PasswordProvider({ + async sendCode() {}, + login: { + async get() { + return `
` + }, + }, + }), + }, + async success(ctx, value) { + return ctx.subject("user", { userID: "123" }) + }, + }) + + const response = await app.request( + "/.well-known/oauth-authorization-server", + ) + expect(response.status).toBe(200) + + const body = await response.json() + expect(body.grant_types_supported).not.toContain("client_credentials") + expect(body.grant_types_supported).toContain("authorization_code") + expect(body.grant_types_supported).toContain("refresh_token") + }) + + test("well-known response includes all required fields", async () => { + const app = issuer({ + storage: MemoryStorage(), + subjects, + providers: { + clientCredentials: ClientCredentialsProvider({ + async verify() { + return { scopes: ["read"] } + }, + }), + }, + async success(ctx, value) { + return ctx.subject("user", { userID: "123" }) + }, + }) + + const response = await app.request( + "/.well-known/oauth-authorization-server", + ) + const body = await response.json() + + expect(body).toHaveProperty("issuer") + expect(body).toHaveProperty("authorization_endpoint") + expect(body).toHaveProperty("token_endpoint") + expect(body).toHaveProperty("jwks_uri") + expect(body).toHaveProperty("response_types_supported") + expect(body).toHaveProperty("grant_types_supported") + + // Verify endpoints are properly formed + expect(body.authorization_endpoint).toMatch(/\/authorize$/) + expect(body.token_endpoint).toMatch(/\/token$/) + expect(body.jwks_uri).toMatch(/\/.well-known\/jwks\.json$/) + }) +}) diff --git a/www/generate.ts b/www/generate.ts index 52332d38..bc066853 100644 --- a/www/generate.ts +++ b/www/generate.ts @@ -162,6 +162,11 @@ const FRONTMATTER: Record< description: "Reference doc for the `CodeProvider`.", editUrl: `${config.github}/blob/master/packages/openauth/src/provider/code.ts`, }, + "client-credentials": { + title: "ClientCredentialsProvider", + description: "Reference doc for the `ClientCredentialsProvider`.", + editUrl: `${config.github}/blob/master/packages/openauth/src/provider/client-credentials.ts`, + }, } renderSubject() @@ -880,6 +885,7 @@ async function build() { "../packages/openauth/src/provider/discord.ts", "../packages/openauth/src/provider/cognito.ts", "../packages/openauth/src/provider/x.ts", + "../packages/openauth/src/provider/client-credentials.ts", "../packages/openauth/src/subject.ts", "../packages/openauth/src/ui/theme.ts", "../packages/openauth/src/ui/code.tsx", diff --git a/www/src/content/docs/docs/provider/client-credentials.mdx b/www/src/content/docs/docs/provider/client-credentials.mdx new file mode 100644 index 00000000..70ff8a55 --- /dev/null +++ b/www/src/content/docs/docs/provider/client-credentials.mdx @@ -0,0 +1,219 @@ +--- +title: Client Credentials +description: Machine-to-machine authentication using OAuth 2.0 client credentials +--- + +The Client Credentials provider enables machine-to-machine authentication using the OAuth 2.0 client credentials grant type. This is ideal for scenarios where applications need to authenticate themselves rather than acting on behalf of a user. + +## When to use + +Use the Client Credentials provider for: +- API-to-API communication +- Backend services authentication +- Microservices authentication +- Scheduled jobs and batch processes +- Any scenario where a service needs to authenticate without user interaction + +## Setup + +```ts +import { ClientCredentialsProvider } from "@openauthjs/openauth/provider/client-credentials" + +export default issuer({ + providers: { + clientCredentials: ClientCredentialsProvider({ + async verify(clientID, clientSecret, requestedScopes) { + // Implement your verification logic + const client = await db.getClient(clientID) + + if (!client || !await bcrypt.compare(clientSecret, client.hashedSecret)) { + throw new Error("Invalid client credentials") + } + + // Optionally validate requested scopes + if (requestedScopes && requestedScopes.length > 0) { + const invalidScopes = requestedScopes.filter( + scope => !client.allowedScopes.includes(scope) + ) + if (invalidScopes.length > 0) { + throw new Error(`Invalid scopes: ${invalidScopes.join(", ")}`) + } + return { scopes: requestedScopes } + } + + return { + scopes: client.allowedScopes, + properties: { + tier: client.tier, + name: client.name + } + } + } + }) + } +}) +``` + +## Config + +### `verify` + +An async function that validates client credentials and returns allowed scopes and properties. + +**Parameters:** +- `clientID` - The client identifier +- `clientSecret` - The client secret +- `requestedScopes` - Optional array of requested scopes + +**Returns:** +- `scopes` - Array of allowed scopes +- `properties` - Optional object with additional properties to include in the token + +**Example:** + +```ts +async verify(clientID, clientSecret, requestedScopes) { + // Your verification logic here + return { + scopes: ["read:users", "write:users"], + properties: { + tier: "premium", + region: "us-east-1" + } + } +} +``` + +## Authentication Flow + +1. Client sends a POST request to `/token` with: + ``` + grant_type=client_credentials + provider=clientCredentials + client_id= + client_secret= + scope= + ``` + +2. The provider's `verify` function validates the credentials + +3. If valid, an access token is issued with the specified scopes + +## Client Usage + +```bash +curl -X POST https://auth.example.com/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "provider=clientCredentials" \ + -d "client_id=my-service" \ + -d "client_secret=my-secret" \ + -d "scope=read:users write:users" +``` + +**Response:** +```json +{ + "access_token": "eyJhbGc...", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +Note: The client credentials grant does not return a refresh token. When the access token expires, the client should request a new token using its credentials. + +## Subject Mapping + +In your success handler, map the client to a subject: + +```ts +async success(ctx, value) { + if (value.provider === "clientCredentials") { + return ctx.subject("service", { + serviceID: value.clientID, + scopes: value.scopes, + ...value.properties + }) + } +} +``` + +## Security Considerations + +- **Secure Storage**: Always use secure hashing (bcrypt, argon2) for client secrets +- **Rate Limiting**: Implement rate limiting to prevent brute force attacks +- **IP Whitelisting**: Consider restricting clients to specific IP addresses +- **Secret Rotation**: Implement a process for regular client secret rotation +- **Audit Logging**: Log all authentication attempts for security monitoring +- **HTTPS Only**: Always use HTTPS for all communications + +## Example Implementation + +Here's a complete example with a mock database: + +```ts +import { issuer } from "@openauthjs/openauth" +import { ClientCredentialsProvider } from "@openauthjs/openauth/provider/client-credentials" +import bcrypt from "bcrypt" + +// Mock client database +const clients = { + "api-service-1": { + hashedSecret: await bcrypt.hash("secret-1", 10), + allowedScopes: ["read:users", "read:posts"], + tier: "basic" + }, + "api-service-2": { + hashedSecret: await bcrypt.hash("secret-2", 10), + allowedScopes: ["read:users", "write:users", "read:posts", "write:posts"], + tier: "premium" + } +} + +export default issuer({ + providers: { + clientCredentials: ClientCredentialsProvider({ + async verify(clientID, clientSecret, requestedScopes) { + const client = clients[clientID] + + if (!client) { + throw new Error("Invalid client_id") + } + + const validSecret = await bcrypt.compare(clientSecret, client.hashedSecret) + if (!validSecret) { + throw new Error("Invalid client_secret") + } + + // Validate scopes if requested + if (requestedScopes && requestedScopes.length > 0) { + const invalidScopes = requestedScopes.filter( + scope => !client.allowedScopes.includes(scope) + ) + if (invalidScopes.length > 0) { + throw new Error(`Invalid scopes: ${invalidScopes.join(", ")}`) + } + return { + scopes: requestedScopes, + properties: { tier: client.tier } + } + } + + return { + scopes: client.allowedScopes, + properties: { tier: client.tier } + } + } + }) + }, + async success(ctx, value) { + if (value.provider === "clientCredentials") { + return ctx.subject("service", { + serviceID: value.clientID, + scopes: value.scopes, + tier: value.properties?.tier + }) + } + } +}) +``` \ No newline at end of file