feat(oidc-provider): Implement OIDC rfc7591 compliant /register endpoint (#1732)

This commit is contained in:
Tommy D. Rossi
2025-03-08 20:43:43 +01:00
committed by GitHub
parent dd31d6c9e3
commit 2a495ea24b
4 changed files with 181 additions and 34 deletions

View File

@@ -242,3 +242,7 @@
@apply bg-background text-foreground;
}
}
html {
scroll-behavior: smooth;
}

View File

@@ -85,14 +85,42 @@ Once installed, you can utilize the OIDC Provider to manage authentication flows
To register a new OIDC client, use the `oauth2.register` method.
<Endpoint path="/oauth2/register" method="POST" />
```ts title="client.ts"
const application = await client.oauth2.register({
name: "My Client",
redirectURLs: ["https://client.example.com/callback"],
redirect_uris: ["https://client.example.com/callback"],
});
```
Once the application is created, you will receive a `clientId` and `clientSecret` that you can display to the user.
Once the application is created, you will receive a `client_id` and `client_secret` that you can display to the user.
This Endpoint support [RFC7591](https://datatracker.ietf.org/doc/html/rfc7591) compliant client registration.
### UserInfo Endpoint
The OIDC Provider includes a UserInfo endpoint that allows clients to retrieve information about the authenticated user. This endpoint is available at `/oauth2/userinfo` and requires a valid access token.
<Endpoint path="/oauth2/userinfo" method="GET" />
```ts title="client-app.ts"
// Example of how a client would use the UserInfo endpoint
const response = await fetch('https://your-domain.com/api/auth/oauth2/userinfo', {
headers: {
'Authorization': 'Bearer ACCESS_TOKEN'
}
});
const userInfo = await response.json();
// userInfo contains user details based on the scopes granted
```
The UserInfo endpoint returns different claims based on the scopes that were granted during authorization:
- With `openid` scope: Returns the user's ID (`sub` claim)
- With `profile` scope: Returns name, picture, given_name, family_name
- With `email` scope: Returns email and email_verified
### Consent Screen
@@ -110,6 +138,8 @@ export const auth = betterAuth({
The plugin will redirect the user to the specified path with a `client_id` and `scope` query parameter. You can use this information to display a custom consent screen. Once the user consents, you can call `oauth2.consent` to complete the authorization.
<Endpoint path="/oauth2/consent" method="POST" />
```ts title="server.ts"
const res = await client.oauth2.consent({
accept: true, // or false to deny

View File

@@ -695,48 +695,156 @@ export const oidcProvider = (options: OIDCOptions) => {
{
method: "POST",
body: z.object({
name: z.string(),
icon: z.string().optional(),
redirect_uris: z.array(z.string()),
token_endpoint_auth_method: z
.enum(["none", "client_secret_basic", "client_secret_post"])
.default("client_secret_basic")
.optional(),
grant_types: z
.array(
z.enum([
"authorization_code",
"implicit",
"password",
"client_credentials",
"refresh_token",
"urn:ietf:params:oauth:grant-type:jwt-bearer",
"urn:ietf:params:oauth:grant-type:saml2-bearer",
]),
)
.default(["authorization_code"])
.optional(),
response_types: z
.array(z.enum(["code", "token"]))
.default(["code"])
.optional(),
client_name: z.string().optional(),
client_uri: z.string().optional(),
logo_uri: z.string().optional(),
scope: z.string().optional(),
contacts: z.array(z.string()).optional(),
tos_uri: z.string().optional(),
policy_uri: z.string().optional(),
jwks_uri: z.string().optional(),
jwks: z.record(z.any()).optional(),
metadata: z.record(z.any()).optional(),
redirectURLs: z.array(z.string()),
software_id: z.string().optional(),
software_version: z.string().optional(),
software_statement: z.string().optional(),
}),
},
async (ctx) => {
const body = ctx.body;
const session = await getSessionFromCtx(ctx);
// Check authorization
if (!session && !options.allowDynamicClientRegistration) {
throw new APIError("UNAUTHORIZED", {
message: "Unauthorized",
error: "invalid_token",
error_description:
"Authentication required for client registration",
});
}
// Validate redirect URIs for redirect-based flows
if (
(!body.grant_types ||
body.grant_types.includes("authorization_code") ||
body.grant_types.includes("implicit")) &&
(!body.redirect_uris || body.redirect_uris.length === 0)
) {
throw new APIError("BAD_REQUEST", {
error: "invalid_redirect_uri",
error_description:
"Redirect URIs are required for authorization_code and implicit grant types",
});
}
// Validate correlation between grant_types and response_types
if (body.grant_types && body.response_types) {
if (
body.grant_types.includes("authorization_code") &&
!body.response_types.includes("code")
) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"When 'authorization_code' grant type is used, 'code' response type must be included",
});
}
if (
body.grant_types.includes("implicit") &&
!body.response_types.includes("token")
) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"When 'implicit' grant type is used, 'token' response type must be included",
});
}
}
const clientId =
options.generateClientId?.() ||
generateRandomString(32, "a-z", "A-Z");
const clientSecret =
options.generateClientSecret?.() ||
generateRandomString(32, "a-z", "A-Z");
const client = await ctx.context.adapter.create<Record<string, any>>({
// Create the client with the existing schema
const client: Client = await ctx.context.adapter.create({
model: modelName.oauthClient,
data: {
name: body.name,
icon: body.icon,
name: body.client_name,
icon: body.logo_uri,
metadata: body.metadata ? JSON.stringify(body.metadata) : null,
clientId: clientId,
clientSecret: clientSecret,
redirectURLs: body.redirectURLs.join(","),
redirectURLs: body.redirect_uris.join(","),
type: "web",
authenticationScheme: "client_secret",
authenticationScheme:
body.token_endpoint_auth_method || "client_secret_basic",
disabled: false,
userId: session?.session.userId,
createdAt: new Date(),
updatedAt: new Date(),
},
});
return ctx.json({
...client,
redirectURLs: client.redirectURLs.split(","),
metadata: client.metadata ? JSON.parse(client.metadata) : null,
} as Client);
// Format the response according to RFC7591
return ctx.json(
{
client_id: clientId,
client_secret: clientSecret,
client_id_issued_at: Math.floor(Date.now() / 1000),
client_secret_expires_at: 0, // 0 means it doesn't expire
redirect_uris: body.redirect_uris,
token_endpoint_auth_method:
body.token_endpoint_auth_method || "client_secret_basic",
grant_types: body.grant_types || ["authorization_code"],
response_types: body.response_types || ["code"],
client_name: body.client_name,
client_uri: body.client_uri,
logo_uri: body.logo_uri,
scope: body.scope,
contacts: body.contacts,
tos_uri: body.tos_uri,
policy_uri: body.policy_uri,
jwks_uri: body.jwks_uri,
jwks: body.jwks,
software_id: body.software_id,
software_version: body.software_version,
software_statement: body.software_statement,
metadata: body.metadata,
},
{
status: 201,
headers: {
"Cache-Control": "no-store",
Pragma: "no-cache",
},
},
);
},
),
getOAuthClient: createAuthEndpoint(

View File

@@ -62,28 +62,33 @@ describe("oidc", async () => {
it("should create oidc client", async ({ expect }) => {
const createdClient = await serverClient.oauth2.register({
name: application.name,
redirectURLs: application.redirectURLs,
icon: application.icon,
metadata: {
custom: "data",
},
client_name: application.name,
redirect_uris: application.redirectURLs,
logo_uri: application.icon,
});
expect(createdClient.data).toMatchObject({
id: expect.any(String),
name: "test",
icon: "",
metadata: {
custom: "data",
},
clientId: expect.any(String),
clientSecret: expect.any(String),
redirectURLs: ["http://localhost:3000/api/auth/oauth2/callback/test"],
type: "web",
disabled: false,
client_id: expect.any(String),
client_secret: expect.any(String),
client_name: "test",
logo_uri: "",
redirect_uris: ["http://localhost:3000/api/auth/oauth2/callback/test"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "client_secret_basic",
client_id_issued_at: expect.any(Number),
client_secret_expires_at: 0,
});
if (createdClient.data) {
application = createdClient.data;
application = {
clientId: createdClient.data.client_id,
clientSecret: createdClient.data.client_secret,
redirectURLs: createdClient.data.redirect_uris,
metadata: {},
icon: createdClient.data.logo_uri || "",
type: "web",
disabled: false,
name: createdClient.data.client_name || "",
};
}
});