feat(oidc-provider): support encrypting and hashing secrets (#3416)

* (feat:oidcProvider): Trusted Client implementation

- Add trustedClients configuration to OIDCOptions
- Add skipConsent property to Client interface
- Implement unified getClient() function for layered lookup
- Update authorize and token flows to support trusted clients
- Add comprehensive documentation and examples

* Apply suggestions from code review

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* lint

* fix: model name

* feat(oidc): implement secure client secret storage options

* chore: cleanup

---------

Co-authored-by: BadPirate <badpirate@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Bereket Engida
2025-07-16 19:54:11 -07:00
committed by GitHub
co-authored by BadPirate cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
parent 82b6974ffa
commit d4fc7b4a7f
4 changed files with 282 additions and 4 deletions
@@ -8,7 +8,11 @@ import {
sessionMiddleware,
} from "../../api";
import type { BetterAuthPlugin, GenericEndpointContext } from "../../types";
import { generateRandomString } from "../../crypto";
import {
generateRandomString,
symmetricDecrypt,
symmetricEncrypt,
} from "../../crypto";
import { schema } from "./schema";
import type {
Client,
@@ -23,6 +27,7 @@ import { createHash } from "@better-auth/utils/hash";
import { base64 } from "@better-auth/utils/base64";
import { getJwtToken } from "../jwt/sign";
import type { JwtOptions } from "../jwt";
import { defaultClientSecretHasher } from "./utils";
const getJwtPlugin = (ctx: GenericEndpointContext) => {
return ctx.context.options.plugins?.find(
@@ -135,6 +140,7 @@ export const oidcProvider = (options: OIDCOptions) => {
accessTokenExpiresIn: 3600,
refreshTokenExpiresIn: 604800,
allowPlainCodeChallengeMethod: true,
storeClientSecret: "plain" as const,
...options,
scopes: [
"openid",
@@ -147,6 +153,78 @@ export const oidcProvider = (options: OIDCOptions) => {
const trustedClients = options.trustedClients || [];
/**
* Store client secret according to the configured storage method
*/
async function storeClientSecret(
ctx: GenericEndpointContext,
clientSecret: string,
) {
if (opts.storeClientSecret === "encrypted") {
return await symmetricEncrypt({
key: ctx.context.secret,
data: clientSecret,
});
}
if (opts.storeClientSecret === "hashed") {
return await defaultClientSecretHasher(clientSecret);
}
if (
typeof opts.storeClientSecret === "object" &&
"hash" in opts.storeClientSecret
) {
return await opts.storeClientSecret.hash(clientSecret);
}
if (
typeof opts.storeClientSecret === "object" &&
"encrypt" in opts.storeClientSecret
) {
return await opts.storeClientSecret.encrypt(clientSecret);
}
return clientSecret;
}
/**
* Verify stored client secret against provided client secret
*/
async function verifyStoredClientSecret(
ctx: GenericEndpointContext,
storedClientSecret: string,
clientSecret: string,
): Promise<boolean> {
if (opts.storeClientSecret === "encrypted") {
return (
(await symmetricDecrypt({
key: ctx.context.secret,
data: storedClientSecret,
})) === clientSecret
);
}
if (opts.storeClientSecret === "hashed") {
const hashedClientSecret = await defaultClientSecretHasher(clientSecret);
return hashedClientSecret === storedClientSecret;
}
if (
typeof opts.storeClientSecret === "object" &&
"hash" in opts.storeClientSecret
) {
const hashedClientSecret =
await opts.storeClientSecret.hash(clientSecret);
return hashedClientSecret === storedClientSecret;
}
if (
typeof opts.storeClientSecret === "object" &&
"decrypt" in opts.storeClientSecret
) {
const decryptedClientSecret =
await opts.storeClientSecret.decrypt(storedClientSecret);
return decryptedClientSecret === clientSecret;
}
return clientSecret === storedClientSecret;
}
return {
id: "oidc",
hooks: {
@@ -556,8 +634,11 @@ export const oidcProvider = (options: OIDCOptions) => {
error: "invalid_client",
});
}
const isValidSecret =
client.clientSecret === client_secret.toString();
const isValidSecret = await verifyStoredClientSecret(
ctx,
client.clientSecret,
client_secret.toString(),
);
if (!isValidSecret) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid client_secret",
@@ -1083,6 +1164,8 @@ export const oidcProvider = (options: OIDCOptions) => {
options.generateClientSecret?.() ||
generateRandomString(32, "a-z", "A-Z");
const storedClientSecret = await storeClientSecret(ctx, clientSecret);
// Create the client with the existing schema
const client: Client = await ctx.context.adapter.create({
model: modelName.oauthClient,
@@ -1091,7 +1174,7 @@ export const oidcProvider = (options: OIDCOptions) => {
icon: body.logo_uri,
metadata: body.metadata ? JSON.stringify(body.metadata) : null,
clientId: clientId,
clientSecret: clientSecret,
clientSecret: storedClientSecret,
redirectURLs: body.redirect_uris.join(","),
type: "web",
authenticationScheme:
@@ -344,6 +344,165 @@ describe("oidc", async () => {
});
});
describe("oidc storage", async () => {
test.each([
{
storeClientSecret: undefined,
},
{
storeClientSecret: "hashed" as const,
},
{
storeClientSecret: "encrypted" as const,
},
])("OIDC base test", async ({ storeClientSecret }) => {
const {
auth: authorizationServer,
signInWithTestUser,
customFetchImpl,
testUser,
} = await getTestInstance({
baseURL: "http://localhost:3000",
plugins: [
oidcProvider({
loginPage: "/login",
consentPage: "/oauth2/authorize",
requirePKCE: true,
getAdditionalUserInfoClaim(user, scopes) {
return {
custom: "custom value",
userId: user.id,
};
},
storeClientSecret,
}),
jwt(),
],
});
const { headers } = await signInWithTestUser();
const serverClient = createAuthClient({
plugins: [oidcClient()],
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers,
},
});
let server = await listen(toNodeHandler(authorizationServer.handler), {
port: 3000,
});
let application: Client = {
clientId: "test-client-id",
clientSecret: "test-client-secret-oidc",
redirectURLs: ["http://localhost:3000/api/auth/oauth2/callback/test"],
metadata: {},
icon: "",
type: "web",
disabled: false,
name: "test",
};
const createdClient = await serverClient.oauth2.register({
client_name: application.name,
redirect_uris: application.redirectURLs,
logo_uri: application.icon,
});
expect(createdClient.data).toMatchObject({
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 = {
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 || "",
};
}
// The RP (Relying Party) - the client application
const { customFetchImpl: customFetchImplRP } = await getTestInstance({
account: {
accountLinking: {
trustedProviders: ["test"],
},
},
plugins: [
genericOAuth({
config: [
{
providerId: "test",
clientId: application.clientId,
clientSecret: application.clientSecret,
authorizationUrl:
"http://localhost:3000/api/auth/oauth2/authorize",
tokenUrl: "http://localhost:3000/api/auth/oauth2/token",
scopes: ["openid", "profile", "email"],
pkce: true,
},
],
}),
],
});
const client = createAuthClient({
plugins: [genericOAuthClient()],
baseURL: "http://localhost:5000",
fetchOptions: {
customFetchImpl: customFetchImplRP,
},
});
const data = await client.signIn.oauth2(
{
providerId: "test",
callbackURL: "/dashboard",
},
{
throw: true,
},
);
expect(data.url).toContain(
"http://localhost:3000/api/auth/oauth2/authorize",
);
expect(data.url).toContain(`client_id=${application.clientId}`);
let redirectURI = "";
await serverClient.$fetch(data.url, {
method: "GET",
onError(context) {
redirectURI = context.response.headers.get("Location") || "";
},
});
expect(redirectURI).toContain(
"http://localhost:3000/api/auth/oauth2/callback/test?code=",
);
let callbackURL = "";
await client.$fetch(redirectURI, {
onError(context) {
callbackURL = context.response.headers.get("Location") || "";
},
});
expect(callbackURL).toContain("/dashboard");
afterEach(async () => {
await server.close();
});
});
});
describe("oidc-jwt", async () => {
let server: Listener | null = null;
test.each([
@@ -126,6 +126,27 @@ export interface OIDCOptions {
* These clients bypass database lookups and can optionally skip consent screens.
*/
trustedClients?: Client[];
/**
* Store the client secret in your database in a secure way
* Note: This will not affect the client secret sent to the user, it will only affect the client secret stored in your database
*
* - "hashed" - The client secret is hashed using the `hash` function.
* - "plain" - The client secret is stored in the database in plain text.
* - "encrypted" - The client secret is encrypted using the `encrypt` function.
* - { hash: (clientSecret: string) => Promise<string> } - A function that hashes the client secret.
* - { encrypt: (clientSecret: string) => Promise<string>, decrypt: (clientSecret: string) => Promise<string> } - A function that encrypts and decrypts the client secret.
*
* @default "plain"
*/
storeClientSecret?:
| "hashed"
| "plain"
| "encrypted"
| { hash: (clientSecret: string) => Promise<string> }
| {
encrypt: (clientSecret: string) => Promise<string>;
decrypt: (clientSecret: string) => Promise<string>;
};
/**
* Whether to use the JWT plugin to sign the ID token.
*
@@ -0,0 +1,15 @@
import { base64Url } from "@better-auth/utils/base64";
import { createHash } from "@better-auth/utils/hash";
/**
* Default client secret hasher using SHA-256
*/
export const defaultClientSecretHasher = async (clientSecret: string) => {
const hash = await createHash("SHA-256").digest(
new TextEncoder().encode(clientSecret),
);
const hashed = base64Url.encode(new Uint8Array(hash), {
padding: false,
});
return hashed;
};