mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-26 02:16:23 -05:00
feat(oauth-provider): compute at_hash in id tokens per OIDC Core §3.1.3.6 (#9079)
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@better-auth/oauth-provider": minor
|
||||
"better-auth": minor
|
||||
---
|
||||
|
||||
feat(oauth-provider): compute `at_hash` in ID tokens per OIDC Core §3.1.3.6
|
||||
|
||||
ID tokens issued alongside an access token now include the `at_hash` claim, which cryptographically binds the two tokens to prevent token substitution attacks. The hash algorithm is selected based on the actual signing key's algorithm (EdDSA/Ed25519 uses SHA-512, RS/ES/PS384 uses SHA-384, RS/ES/PS512 uses SHA-512, all others use SHA-256).
|
||||
|
||||
A new `resolveSigningKey()` export is available from `better-auth/plugins` to resolve the current JWKS signing key (including its algorithm). When using a custom `jwt.sign` callback, the signed ID token's header is validated against the declared algorithm to prevent `at_hash` mismatches.
|
||||
@@ -16,7 +16,7 @@ import type { JwtOptions } from "./types";
|
||||
import { createJwk } from "./utils";
|
||||
import { verifyJWT as verifyJWTHelper } from "./verify";
|
||||
|
||||
export { signJWT } from "./sign";
|
||||
export { resolveSigningKey, signJWT } from "./sign";
|
||||
export type * from "./types";
|
||||
export { createJwk, generateExportedKeyPair, toExpJWT } from "./utils";
|
||||
export { verifyJWT } from "./verify";
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { JWTPayload } from "jose";
|
||||
import { importJWK, SignJWT } from "jose";
|
||||
import { symmetricDecrypt } from "../../crypto";
|
||||
import { getJwksAdapter } from "./adapter";
|
||||
import type { JwtOptions } from "./types";
|
||||
import type { JwtOptions, ResolvedSigningKey } from "./types";
|
||||
import { createJwk, toExpJWT } from "./utils";
|
||||
|
||||
type JWTPayloadWithOptional = {
|
||||
@@ -61,11 +61,52 @@ type JWTPayloadWithOptional = {
|
||||
[propName: string]: unknown | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the JWKS signing key, decrypts it, and imports it
|
||||
* for use with jose's SignJWT. Returns null when signing is
|
||||
* delegated to a custom jwt.sign callback.
|
||||
*
|
||||
* Callers that need the signing algorithm before constructing
|
||||
* the JWT payload (e.g. for OIDC at_hash) should call this
|
||||
* first, read `.alg`, then pass the result to `signJWT` via
|
||||
* the `resolvedKey` option to avoid a redundant DB lookup.
|
||||
*/
|
||||
export async function resolveSigningKey(
|
||||
ctx: GenericEndpointContext,
|
||||
options?: JwtOptions,
|
||||
): Promise<ResolvedSigningKey | null> {
|
||||
if (options?.jwt?.sign) {
|
||||
return null;
|
||||
}
|
||||
const adapter = getJwksAdapter(ctx.context.adapter, options);
|
||||
let key = await adapter.getLatestKey(ctx);
|
||||
if (!key || (key.expiresAt && key.expiresAt < new Date())) {
|
||||
key = await createJwk(ctx, options);
|
||||
}
|
||||
const privateKeyEncryptionEnabled =
|
||||
!options?.jwks?.disablePrivateKeyEncryption;
|
||||
const privateWebKey = privateKeyEncryptionEnabled
|
||||
? await symmetricDecrypt({
|
||||
key: ctx.context.secretConfig,
|
||||
data: JSON.parse(key.privateKey),
|
||||
}).catch(() => {
|
||||
throw new BetterAuthError(
|
||||
"Failed to decrypt private key. Make sure the secret currently in use is the same as the one used to encrypt the private key. If you are using a different secret, either clean up your JWKS or disable private key encryption.",
|
||||
);
|
||||
})
|
||||
: key.privateKey;
|
||||
const alg = key.alg ?? options?.jwks?.keyPairConfig?.alg ?? "EdDSA";
|
||||
const privateKey = await importJWK(JSON.parse(privateWebKey), alg);
|
||||
return { alg, kid: key.id, privateKey };
|
||||
}
|
||||
|
||||
export async function signJWT(
|
||||
ctx: GenericEndpointContext,
|
||||
config: {
|
||||
options?: JwtOptions | undefined;
|
||||
payload: JWTPayloadWithOptional;
|
||||
/** Pre-resolved key from resolveSigningKey. Skips redundant DB lookup. */
|
||||
resolvedKey?: ResolvedSigningKey;
|
||||
},
|
||||
) {
|
||||
const { options } = config;
|
||||
@@ -113,31 +154,14 @@ export async function signJWT(
|
||||
return options.jwt.sign(jwtPayload);
|
||||
}
|
||||
|
||||
const adapter = getJwksAdapter(ctx.context.adapter, options);
|
||||
let key = await adapter.getLatestKey(ctx);
|
||||
if (!key || (key.expiresAt && key.expiresAt < new Date())) {
|
||||
key = await createJwk(ctx, options);
|
||||
}
|
||||
const privateKeyEncryptionEnabled =
|
||||
!options?.jwks?.disablePrivateKeyEncryption;
|
||||
|
||||
const privateWebKey = privateKeyEncryptionEnabled
|
||||
? await symmetricDecrypt({
|
||||
key: ctx.context.secretConfig,
|
||||
data: JSON.parse(key.privateKey),
|
||||
}).catch(() => {
|
||||
throw new BetterAuthError(
|
||||
"Failed to decrypt private key. Make sure the secret currently in use is the same as the one used to encrypt the private key. If you are using a different secret, either clean up your JWKS or disable private key encryption.",
|
||||
);
|
||||
})
|
||||
: key.privateKey;
|
||||
const alg = key.alg ?? options?.jwks?.keyPairConfig?.alg ?? "EdDSA";
|
||||
const privateKey = await importJWK(JSON.parse(privateWebKey), alg);
|
||||
// Use pre-resolved key if available, otherwise resolve from DB
|
||||
const { alg, kid, privateKey } =
|
||||
config.resolvedKey ?? (await resolveSigningKey(ctx, options))!;
|
||||
|
||||
const jwt = new SignJWT(payload)
|
||||
.setProtectedHeader({
|
||||
alg,
|
||||
kid: key.id,
|
||||
kid,
|
||||
})
|
||||
.setExpirationTime(exp)
|
||||
.setIssuer(iss ?? defaultIss)
|
||||
|
||||
@@ -206,3 +206,16 @@ export interface Jwk {
|
||||
alg?: JWSAlgorithms | undefined;
|
||||
crv?: ("Ed25519" | "P-256" | "P-521") | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fully resolved signing key ready for JWT signing.
|
||||
* Produced by `resolveSigningKey`, consumed by `signJWT`.
|
||||
* Separates key resolution from signing so callers can
|
||||
* read the `alg` before constructing the JWT payload
|
||||
* (required for OIDC hash claims like at_hash).
|
||||
*/
|
||||
export interface ResolvedSigningKey {
|
||||
alg: string;
|
||||
kid: string;
|
||||
privateKey: CryptoKey | Uint8Array;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "better-auth/oauth2";
|
||||
import { jwt } from "better-auth/plugins/jwt";
|
||||
import { getTestInstance } from "better-auth/test";
|
||||
import { createLocalJWKSet, decodeJwt, jwtVerify } from "jose";
|
||||
import { base64url, createLocalJWKSet, decodeJwt, jwtVerify } from "jose";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { oauthProviderClient } from "./client";
|
||||
import { oauthProvider } from "./oauth";
|
||||
@@ -1490,7 +1490,6 @@ describe("oauth token - config", async () => {
|
||||
const refreshedTokens = await client.oauth2.token(
|
||||
{
|
||||
resource: validAudience,
|
||||
// @ts-expect-error refresh token is sent
|
||||
refresh_token: tokens.data?.refresh_token,
|
||||
grant_type: "refresh_token",
|
||||
client_id: oauthClient?.client_id,
|
||||
@@ -1600,7 +1599,6 @@ describe("oauth token - config", async () => {
|
||||
// Refresh token
|
||||
const refreshedTokens = await client.oauth2.token(
|
||||
{
|
||||
// @ts-expect-error refresh token is sent
|
||||
refresh_token: tokens.data?.refresh_token,
|
||||
grant_type: "refresh_token",
|
||||
client_id: oauthClient?.client_id,
|
||||
@@ -2235,3 +2233,238 @@ describe("scope preservation through authorization code flow", async () => {
|
||||
expect(tokens.data?.scope).toBe(requestedScopes.join(" "));
|
||||
});
|
||||
});
|
||||
|
||||
describe("at_hash in id tokens", async () => {
|
||||
const authServerBaseUrl = "http://localhost:3000";
|
||||
const rpBaseUrl = "http://localhost:5000";
|
||||
const validAudience = "https://myapi.example.com";
|
||||
const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({
|
||||
baseURL: authServerBaseUrl,
|
||||
plugins: [
|
||||
jwt({
|
||||
jwt: {
|
||||
issuer: authServerBaseUrl,
|
||||
},
|
||||
}),
|
||||
oauthProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/consent",
|
||||
validAudiences: [validAudience],
|
||||
silenceWarnings: {
|
||||
oauthAuthServerConfig: true,
|
||||
openidConfig: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { headers } = await signInWithTestUser();
|
||||
const client = createAuthClient({
|
||||
plugins: [oauthProviderClient(), jwtClient()],
|
||||
baseURL: authServerBaseUrl,
|
||||
fetchOptions: {
|
||||
customFetchImpl,
|
||||
headers,
|
||||
},
|
||||
});
|
||||
|
||||
let oauthClient: OAuthClient | null;
|
||||
const providerId = "test";
|
||||
const redirectUri = `${rpBaseUrl}/api/auth/oauth2/callback/${providerId}`;
|
||||
const state = "123";
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await auth.api.adminCreateOAuthClient({
|
||||
headers,
|
||||
body: {
|
||||
redirect_uris: [redirectUri],
|
||||
skip_consent: true,
|
||||
},
|
||||
});
|
||||
expect(response?.client_id).toBeDefined();
|
||||
expect(response?.client_secret).toBeDefined();
|
||||
oauthClient = response;
|
||||
});
|
||||
|
||||
async function getTokens(scopes: string[], resource?: string) {
|
||||
if (!oauthClient?.client_id || !oauthClient?.client_secret) {
|
||||
throw Error("beforeAll not run properly");
|
||||
}
|
||||
|
||||
const codeVerifier = generateRandomString(32);
|
||||
const url = await createAuthorizationURL({
|
||||
id: providerId,
|
||||
options: {
|
||||
clientId: oauthClient.client_id,
|
||||
clientSecret: oauthClient.client_secret,
|
||||
redirectURI: redirectUri,
|
||||
},
|
||||
redirectURI: "",
|
||||
authorizationEndpoint: `${authServerBaseUrl}/api/auth/oauth2/authorize`,
|
||||
state,
|
||||
scopes,
|
||||
codeVerifier,
|
||||
});
|
||||
|
||||
let callbackRedirectUrl = "";
|
||||
await client.$fetch(url.toString(), {
|
||||
onError(context) {
|
||||
callbackRedirectUrl = context.response.headers.get("Location") || "";
|
||||
},
|
||||
});
|
||||
const callbackUrl = new URL(callbackRedirectUrl);
|
||||
const code = callbackUrl.searchParams.get("code")!;
|
||||
|
||||
const { body, headers: reqHeaders } = createAuthorizationCodeRequest({
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectURI: redirectUri,
|
||||
options: {
|
||||
clientId: oauthClient.client_id,
|
||||
clientSecret: oauthClient.client_secret,
|
||||
redirectURI: redirectUri,
|
||||
},
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(body.toString());
|
||||
if (resource) {
|
||||
params.set("resource", resource);
|
||||
}
|
||||
|
||||
const tokens = await client.$fetch<{
|
||||
access_token?: string;
|
||||
id_token?: string;
|
||||
[key: string]: unknown;
|
||||
}>("/oauth2/token", {
|
||||
method: "POST",
|
||||
body: params.toString(),
|
||||
headers: reqHeaders,
|
||||
});
|
||||
|
||||
return tokens.data!;
|
||||
}
|
||||
|
||||
it("should include at_hash when id token is issued with access token", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const tokens = await getTokens(["openid"]);
|
||||
expect(tokens.id_token).toBeDefined();
|
||||
expect(tokens.access_token).toBeDefined();
|
||||
|
||||
const decoded = decodeJwt(tokens.id_token!);
|
||||
expect(decoded.at_hash).toBeDefined();
|
||||
expect(typeof decoded.at_hash).toBe("string");
|
||||
expect((decoded.at_hash as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* EdDSA (Ed25519) uses SHA-512 per RFC 8032.
|
||||
* at_hash = base64url(left-half(SHA-512(access_token)))
|
||||
*/
|
||||
it("at_hash should match manual computation for EdDSA", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const tokens = await getTokens(["openid"]);
|
||||
const decoded = decodeJwt(tokens.id_token!);
|
||||
|
||||
const digest = new Uint8Array(
|
||||
await crypto.subtle.digest(
|
||||
"SHA-512",
|
||||
new TextEncoder().encode(tokens.access_token!),
|
||||
),
|
||||
);
|
||||
const expectedAtHash = base64url.encode(digest.slice(0, digest.length / 2));
|
||||
|
||||
expect(decoded.at_hash).toBe(expectedAtHash);
|
||||
});
|
||||
|
||||
it("at_hash should not be present without openid scope", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const tokens = await getTokens(["offline_access"], validAudience);
|
||||
expect(tokens.id_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("customIdTokenClaims should not receive accessToken", async ({
|
||||
expect,
|
||||
}) => {
|
||||
let receivedKeys: string[] = [];
|
||||
const {
|
||||
auth: testAuth,
|
||||
signInWithTestUser: signIn,
|
||||
customFetchImpl: fetchImpl,
|
||||
} = await getTestInstance({
|
||||
baseURL: authServerBaseUrl,
|
||||
plugins: [
|
||||
jwt({ jwt: { issuer: authServerBaseUrl } }),
|
||||
oauthProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/consent",
|
||||
silenceWarnings: {
|
||||
oauthAuthServerConfig: true,
|
||||
openidConfig: true,
|
||||
},
|
||||
customIdTokenClaims: (info) => {
|
||||
receivedKeys = Object.keys(info);
|
||||
return {};
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { headers: testHeaders } = await signIn();
|
||||
const testClient = createAuthClient({
|
||||
plugins: [oauthProviderClient(), jwtClient()],
|
||||
baseURL: authServerBaseUrl,
|
||||
fetchOptions: { customFetchImpl: fetchImpl, headers: testHeaders },
|
||||
});
|
||||
|
||||
const testOauthClient = await testAuth.api.adminCreateOAuthClient({
|
||||
headers: testHeaders,
|
||||
body: { redirect_uris: [redirectUri], skip_consent: true },
|
||||
});
|
||||
|
||||
const codeVerifier = generateRandomString(32);
|
||||
const url = await createAuthorizationURL({
|
||||
id: providerId,
|
||||
options: {
|
||||
clientId: testOauthClient!.client_id!,
|
||||
clientSecret: testOauthClient!.client_secret!,
|
||||
redirectURI: redirectUri,
|
||||
},
|
||||
redirectURI: "",
|
||||
authorizationEndpoint: `${authServerBaseUrl}/api/auth/oauth2/authorize`,
|
||||
state,
|
||||
scopes: ["openid"],
|
||||
codeVerifier,
|
||||
});
|
||||
|
||||
let callbackUrl = "";
|
||||
await testClient.$fetch(url.toString(), {
|
||||
onError(context) {
|
||||
callbackUrl = context.response.headers.get("Location") || "";
|
||||
},
|
||||
});
|
||||
const code = new URL(callbackUrl).searchParams.get("code")!;
|
||||
const { body, headers: reqHeaders } = createAuthorizationCodeRequest({
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectURI: redirectUri,
|
||||
options: {
|
||||
clientId: testOauthClient!.client_id!,
|
||||
clientSecret: testOauthClient!.client_secret!,
|
||||
redirectURI: redirectUri,
|
||||
},
|
||||
});
|
||||
await testClient.$fetch("/oauth2/token", {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: reqHeaders,
|
||||
});
|
||||
|
||||
expect(receivedKeys).toContain("user");
|
||||
expect(receivedKeys).toContain("scopes");
|
||||
expect(receivedKeys).toContain("metadata");
|
||||
expect(receivedKeys).not.toContain("accessToken");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { GenericEndpointContext } from "@better-auth/core";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { generateRandomString } from "better-auth/crypto";
|
||||
import { generateCodeChallenge } from "better-auth/oauth2";
|
||||
import { signJWT, toExpJWT } from "better-auth/plugins";
|
||||
import { resolveSigningKey, signJWT, toExpJWT } from "better-auth/plugins";
|
||||
import type { Session, User } from "better-auth/types";
|
||||
import type { JWTPayload } from "jose";
|
||||
import { SignJWT } from "jose";
|
||||
import { base64url, decodeProtectedHeader, SignJWT } from "jose";
|
||||
import type {
|
||||
OAuthOptions,
|
||||
OAuthRefreshToken,
|
||||
@@ -119,6 +119,31 @@ async function createJwtAccessToken(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes an OIDC hash (at_hash, c_hash) per OIDC Core §3.1.3.6.
|
||||
* Hashes the token, takes the left half, and base64url-encodes it.
|
||||
*/
|
||||
async function computeOidcHash(
|
||||
token: string,
|
||||
signingAlg: string,
|
||||
): Promise<string> {
|
||||
let hashAlg: string;
|
||||
if (signingAlg === "EdDSA") {
|
||||
hashAlg = "SHA-512";
|
||||
} else if (signingAlg.endsWith("384")) {
|
||||
hashAlg = "SHA-384";
|
||||
} else if (signingAlg.endsWith("512")) {
|
||||
hashAlg = "SHA-512";
|
||||
} else {
|
||||
hashAlg = "SHA-256";
|
||||
}
|
||||
|
||||
const digest = new Uint8Array(
|
||||
await crypto.subtle.digest(hashAlg, new TextEncoder().encode(token)),
|
||||
);
|
||||
return base64url.encode(digest.slice(0, digest.length / 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a user id token in code_authorization with scope of 'openid'
|
||||
* and hybrid/implicit (not yet implemented) flows
|
||||
@@ -132,6 +157,7 @@ async function createIdToken(
|
||||
nonce?: string,
|
||||
sessionId?: string,
|
||||
authTime?: Date,
|
||||
accessToken?: string,
|
||||
) {
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + (opts.idTokenExpiresIn ?? 36000);
|
||||
@@ -156,11 +182,26 @@ async function createIdToken(
|
||||
? undefined
|
||||
: getJwtPlugin(ctx.context).options;
|
||||
|
||||
// Resolve the signing key once: used for both at_hash and signing
|
||||
const resolvedKey =
|
||||
!opts.disableJwtPlugin && !jwtPluginOptions?.jwt?.sign
|
||||
? await resolveSigningKey(ctx, jwtPluginOptions)
|
||||
: undefined;
|
||||
|
||||
// For custom signer, alg is guaranteed set by JWT plugin init validation
|
||||
const signingAlg = opts.disableJwtPlugin
|
||||
? "HS256"
|
||||
: (resolvedKey?.alg ?? jwtPluginOptions?.jwks?.keyPairConfig?.alg!);
|
||||
const atHash = accessToken
|
||||
? await computeOidcHash(accessToken, signingAlg)
|
||||
: undefined;
|
||||
|
||||
const payload: JWTPayload = {
|
||||
...userClaims,
|
||||
auth_time: authTimeSec,
|
||||
acr,
|
||||
...customClaims,
|
||||
at_hash: atHash,
|
||||
iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
|
||||
sub: resolvedSub,
|
||||
aud: client.clientId,
|
||||
@@ -176,8 +217,8 @@ async function createIdToken(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return opts.disableJwtPlugin
|
||||
? new SignJWT(payload)
|
||||
const idToken = opts.disableJwtPlugin
|
||||
? await new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.sign(
|
||||
new TextEncoder().encode(
|
||||
@@ -188,10 +229,28 @@ async function createIdToken(
|
||||
),
|
||||
),
|
||||
)
|
||||
: signJWT(ctx, {
|
||||
: await signJWT(ctx, {
|
||||
options: jwtPluginOptions,
|
||||
payload,
|
||||
resolvedKey: resolvedKey ?? undefined,
|
||||
});
|
||||
|
||||
// When using a custom jwt.sign callback, validate that the actual
|
||||
// signing algorithm matches what was used for at_hash (OIDC Core §3.1.3.6)
|
||||
if (idToken && atHash && jwtPluginOptions?.jwt?.sign) {
|
||||
const header = decodeProtectedHeader(idToken);
|
||||
if (header.alg !== signingAlg) {
|
||||
throw new APIError("INTERNAL_SERVER_ERROR", {
|
||||
error_description:
|
||||
`ID token signed with "${header.alg}" but at_hash was computed ` +
|
||||
`for "${signingAlg}". Ensure jwt.sign uses the algorithm ` +
|
||||
`declared in keyPairConfig.alg.`,
|
||||
error: "server_error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return idToken;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -421,8 +480,8 @@ async function createUserTokens(
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Sign jwt and refresh tokens in parallel
|
||||
const [accessToken, refreshToken, idToken] = await Promise.all([
|
||||
// Create access token and refresh token in parallel
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
isJwtAccessToken
|
||||
? createJwtAccessToken(
|
||||
ctx,
|
||||
@@ -471,20 +530,23 @@ async function createUserTokens(
|
||||
authTime,
|
||||
)
|
||||
: undefined,
|
||||
isIdToken
|
||||
? createIdToken(
|
||||
ctx,
|
||||
opts,
|
||||
user,
|
||||
client,
|
||||
scopes,
|
||||
nonce,
|
||||
sessionId,
|
||||
authTime,
|
||||
)
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
// ID token created after access token so at_hash can be computed
|
||||
const idToken = isIdToken
|
||||
? await createIdToken(
|
||||
ctx,
|
||||
opts,
|
||||
user,
|
||||
client,
|
||||
scopes,
|
||||
nonce,
|
||||
sessionId,
|
||||
authTime,
|
||||
accessToken,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return ctx.json(
|
||||
{
|
||||
access_token: accessToken,
|
||||
|
||||
Reference in New Issue
Block a user