mirror of
https://github.com/better-auth/better-auth.git
synced 2026-08-01 10:50:40 -05:00
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:
co-authored by
BadPirate
cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
parent
82b6974ffa
commit
d4fc7b4a7f
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user