-
Notifications
You must be signed in to change notification settings - Fork 141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add OAuth2 scope support #156
base: master
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@openauthjs/openauth": minor | ||
--- | ||
|
||
Add support for OAuth2 scope |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -38,22 +38,22 @@ | |
* | ||
* @packageDocumentation | ||
*/ | ||
import type { v1 } from "@standard-schema/spec" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These got reordered because of |
||
import { | ||
createLocalJWKSet, | ||
decodeJwt, | ||
errors, | ||
JSONWebKeySet, | ||
jwtVerify, | ||
decodeJwt, | ||
} from "jose" | ||
import { SubjectSchema } from "./subject.js" | ||
import type { v1 } from "@standard-schema/spec" | ||
import { | ||
InvalidAccessTokenError, | ||
InvalidAuthorizationCodeError, | ||
InvalidRefreshTokenError, | ||
InvalidSubjectError, | ||
} from "./error.js" | ||
import { generatePKCE } from "./pkce.js" | ||
import { SubjectSchema } from "./subject.js" | ||
|
||
/** | ||
* The well-known information for an OAuth 2.0 authorization server. | ||
|
@@ -173,6 +173,16 @@ export interface AuthorizeOptions { | |
* If there's only one provider configured, the user will be redirected to that. | ||
*/ | ||
provider?: string | ||
/** | ||
* The scopes you want to request. | ||
* | ||
* @example | ||
* ```ts | ||
* { | ||
* scopes: ["read", "write"] | ||
* } | ||
*/ | ||
scopes?: string[] | ||
} | ||
|
||
export interface AuthorizeResult { | ||
|
@@ -316,6 +326,10 @@ export interface VerifyResult<T extends SubjectSchema> { | |
subject: { | ||
[type in keyof T]: { type: type; properties: v1.InferOutput<T[type]> } | ||
}[keyof T] | ||
/** | ||
* The scopes of the token. | ||
*/ | ||
scopes?: string[] | ||
} | ||
|
||
/** | ||
|
@@ -589,6 +603,7 @@ export function createClient(input: ClientInput): Client { | |
result.searchParams.set("code_challenge", pkce.challenge) | ||
challenge.verifier = pkce.verifier | ||
} | ||
if (opts?.scopes) result.searchParams.set("scope", opts.scopes.join(" ")) | ||
return { | ||
challenge, | ||
url: result.toString(), | ||
|
@@ -698,6 +713,7 @@ export function createClient(input: ClientInput): Client { | |
mode: "access" | ||
type: keyof T | ||
properties: v1.InferInput<T[keyof T]> | ||
scopes?: string[] | ||
}>(token, jwks, { | ||
issuer, | ||
}) | ||
|
@@ -711,6 +727,7 @@ export function createClient(input: ClientInput): Client { | |
type: result.payload.type, | ||
properties: validated.value, | ||
} as any, | ||
...(result.payload.scopes ? { scopes: result.payload.scopes } : {}), | ||
} | ||
return { | ||
err: new InvalidSubjectError(), | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -124,12 +124,12 @@ | |
* | ||
* @packageDocumentation | ||
*/ | ||
import { Provider, ProviderOptions } from "./provider/provider.js" | ||
import { SubjectPayload, SubjectSchema } from "./subject.js" | ||
import { Hono } from "hono/tiny" | ||
import { handle as awsHandle } from "hono/aws-lambda" | ||
import { Context } from "hono" | ||
import { handle as awsHandle } from "hono/aws-lambda" | ||
import { deleteCookie, getCookie, setCookie } from "hono/cookie" | ||
import { Hono } from "hono/tiny" | ||
import { Provider, ProviderOptions } from "./provider/provider.js" | ||
import { SubjectPayload, SubjectSchema } from "./subject.js" | ||
|
||
/** | ||
* Sets the subject payload in the JWT token and returns the response. | ||
|
@@ -171,6 +171,7 @@ export interface AuthorizationState { | |
state: string | ||
client_id: string | ||
audience?: string | ||
scopes?: string[] | ||
pkce?: { | ||
challenge: string | ||
method: "S256" | ||
|
@@ -184,22 +185,23 @@ export type Prettify<T> = { | |
[K in keyof T]: T[K] | ||
} & {} | ||
|
||
import { cors } from "hono/cors" | ||
import { compactDecrypt, CompactEncrypt, SignJWT } from "jose" | ||
import { | ||
MissingParameterError, | ||
OauthError, | ||
UnauthorizedClientError, | ||
UnknownStateError, | ||
} from "./error.js" | ||
import { compactDecrypt, CompactEncrypt, SignJWT } from "jose" | ||
import { Storage, StorageAdapter } from "./storage/storage.js" | ||
import { encryptionKeys, legacySigningKeys, signingKeys } from "./keys.js" | ||
import { validatePKCE } from "./pkce.js" | ||
import { parseScopes, validateScopes } from "./scopes.js" | ||
import { DynamoStorage } from "./storage/dynamo.js" | ||
import { MemoryStorage } from "./storage/memory.js" | ||
import { Storage, StorageAdapter } from "./storage/storage.js" | ||
import { Select } from "./ui/select.js" | ||
import { setTheme, Theme } from "./ui/theme.js" | ||
import { isDomainMatch } from "./util.js" | ||
import { DynamoStorage } from "./storage/dynamo.js" | ||
import { MemoryStorage } from "./storage/memory.js" | ||
import { cors } from "hono/cors" | ||
|
||
/** @internal */ | ||
export const aws = awsHandle | ||
|
@@ -279,6 +281,17 @@ export interface IssuerInput< | |
* ``` | ||
*/ | ||
providers: Providers | ||
/** | ||
* Array containing a list of the OAuth 2.0 [RFC6749] "scope" values that this authorization server advertises. | ||
* | ||
* @example | ||
* ```ts | ||
* { | ||
* scopes_supported: ["read", "write"] | ||
* } | ||
* ``` | ||
*/ | ||
scopes_supported?: string[] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This would also be a good place to add |
||
/** | ||
* The theme you want to use for the UI. | ||
* | ||
|
@@ -524,6 +537,7 @@ export function issuer< | |
type: type as string, | ||
properties, | ||
clientID: authorization.client_id, | ||
scopes: authorization.scopes, | ||
ttl: { | ||
access: subjectOpts?.ttl?.access ?? ttlAccess, | ||
refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh, | ||
|
@@ -549,6 +563,7 @@ export function issuer< | |
redirectURI: authorization.redirect_uri, | ||
clientID: authorization.client_id, | ||
pkce: authorization.pkce, | ||
scopes: authorization.scopes, | ||
ttl: { | ||
access: subjectOpts?.ttl?.access ?? ttlAccess, | ||
refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh, | ||
|
@@ -653,6 +668,7 @@ export function issuer< | |
} | ||
timeUsed?: number | ||
nextToken?: string | ||
scopes?: string[] | ||
}, | ||
opts?: { | ||
generateRefreshToken?: boolean | ||
|
@@ -686,6 +702,7 @@ export function issuer< | |
aud: value.clientID, | ||
iss: issuer(ctx), | ||
sub: value.subject, | ||
scopes: value.scopes, | ||
}) | ||
.setExpirationTime( | ||
Math.floor((value.timeUsed ?? Date.now()) / 1000 + value.ttl.access), | ||
|
@@ -775,6 +792,7 @@ export function issuer< | |
token_endpoint: `${iss}/token`, | ||
jwks_uri: `${iss}/.well-known/jwks.json`, | ||
response_types_supported: ["code", "token"], | ||
scopes_supported: input.scopes_supported, | ||
}) | ||
}, | ||
) | ||
|
@@ -790,6 +808,7 @@ export function issuer< | |
async (c) => { | ||
const form = await c.req.formData() | ||
const grantType = form.get("grant_type") | ||
const scope = form.get("scope") as string | null | ||
|
||
if (grantType === "authorization_code") { | ||
const code = form.get("code") | ||
|
@@ -808,6 +827,7 @@ export function issuer< | |
clientID: string | ||
redirectURI: string | ||
subject: string | ||
scopes?: string[] | ||
ttl: { | ||
access: number | ||
refresh: number | ||
|
@@ -871,10 +891,12 @@ export function issuer< | |
) | ||
} | ||
} | ||
payload.scopes = validateScopes(scope, payload.scopes) | ||
const tokens = await generateTokens(c, payload) | ||
return c.json({ | ||
access_token: tokens.access, | ||
refresh_token: tokens.refresh, | ||
scope: payload.scopes?.join(" "), | ||
}) | ||
} | ||
|
||
|
@@ -897,6 +919,7 @@ export function issuer< | |
properties: any | ||
clientID: string | ||
subject: string | ||
scopes?: string[] | ||
ttl: { | ||
access: number | ||
refresh: number | ||
|
@@ -936,12 +959,14 @@ export function issuer< | |
400, | ||
) | ||
} | ||
payload.scopes = validateScopes(scope, payload.scopes) | ||
const tokens = await generateTokens(c, payload, { | ||
generateRefreshToken, | ||
}) | ||
return c.json({ | ||
access_token: tokens.access, | ||
refresh_token: tokens.refresh, | ||
scope: payload.scopes?.join(" "), | ||
}) | ||
} | ||
|
||
|
@@ -977,6 +1002,7 @@ export function issuer< | |
opts?.subject || (await resolveSubject(type, properties)), | ||
properties, | ||
clientID: clientID.toString(), | ||
scopes: parseScopes(scope), | ||
ttl: { | ||
access: opts?.ttl?.access ?? ttlAccess, | ||
refresh: opts?.ttl?.refresh ?? ttlRefresh, | ||
|
@@ -1009,12 +1035,14 @@ export function issuer< | |
const audience = c.req.query("audience") | ||
const code_challenge = c.req.query("code_challenge") | ||
const code_challenge_method = c.req.query("code_challenge_method") | ||
const scope = c.req.query("scope") | ||
const authorization: AuthorizationState = { | ||
response_type, | ||
redirect_uri, | ||
state, | ||
client_id, | ||
audience, | ||
scopes: parseScopes(scope), | ||
pkce: | ||
code_challenge && code_challenge_method | ||
? { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
export function parseScopes(scope: string | null | undefined) { | ||
return scope?.split(" ").filter((s) => s) | ||
} | ||
|
||
export function validateScopes(tokenReq?: string | null, authorizeReq?: string[]) { | ||
if (!authorizeReq?.length || tokenReq === null || tokenReq === undefined) { | ||
return authorizeReq | ||
} | ||
return [...new Set(parseScopes(tokenReq)).intersection(new Set(authorizeReq))] | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if this is right. Haven't used this before either.