Skip to content
Open
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
115 changes: 115 additions & 0 deletions examples/client-credentials-example.ts
Original file line number Diff line number Diff line change
@@ -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
70 changes: 50 additions & 20 deletions packages/openauth/src/issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
},
)
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
132 changes: 132 additions & 0 deletions packages/openauth/src/provider/client-credentials.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>
}>
}

/**
* 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<string, any>
}> {
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
}
},
}
}
Loading