@@ -6,6 +6,11 @@ interface Env {
66 DB ?: D1Database ;
77 CACHE ?: KVNamespace ;
88 IMAGES ?: R2Bucket ;
9+ AUTH_MODE ?: string ;
10+ SSO_ISSUER ?: string ;
11+ SSO_CLIENT_ID ?: string ;
12+ SSO_ENTITLEMENT_TENANT_ID ?: string ;
13+ SSO_REQUIRED_ENTITLEMENT_KEY ?: string ;
914 GITHUB_CLIENT_ID ?: string ;
1015 GITHUB_CLIENT_SECRET ?: string ;
1116 AUTH_SECRET ?: string ;
@@ -90,6 +95,13 @@ type AuthSession = {
9095 exp : number ;
9196} ;
9297
98+ type SsoUserInfo = {
99+ sub : string ;
100+ tid ?: string ;
101+ gaid ?: string ;
102+ email ?: string ;
103+ } ;
104+
93105type GithubUser = {
94106 id : number ;
95107 login : string ;
@@ -211,6 +223,66 @@ const redirectWithCookies = (location: string, cookies: string[] = []): Response
211223
212224const allowHeaderIdentity = ( env : Env ) : boolean => env . ALLOW_HEADER_IDENTITY !== "0" ;
213225
226+ type AuthMode = "legacy" | "hybrid" | "sso" ;
227+
228+ const resolveAuthMode = ( env : Env ) : AuthMode => {
229+ const mode = String ( env . AUTH_MODE ?? "legacy" ) . trim ( ) . toLowerCase ( ) ;
230+ if ( mode === "hybrid" || mode === "sso" ) return mode ;
231+ return "legacy" ;
232+ } ;
233+
234+ const readBearerToken = ( request : Request ) : string | null => {
235+ const raw = request . headers . get ( "authorization" ) ?. trim ( ) ?? "" ;
236+ if ( ! / ^ b e a r e r \s + / i. test ( raw ) ) return null ;
237+ const token = raw . replace ( / ^ b e a r e r \s + / i, "" ) . trim ( ) ;
238+ return token || null ;
239+ } ;
240+
241+ const isJwtLike = ( token : string ) : boolean => {
242+ const parts = token . split ( "." ) ;
243+ return parts . length === 3 && parts . every ( ( part ) => part . length > 0 ) ;
244+ } ;
245+
246+ type SsoIdentityResult =
247+ | { status : "ok" ; identity : Identity ; user : SsoUserInfo }
248+ | { status : "unavailable" }
249+ | { status : "invalid" } ;
250+
251+ const readSsoIdentity = async ( request : Request , env : Env ) : Promise < SsoIdentityResult > => {
252+ const issuer = String ( env . SSO_ISSUER ?? "" ) . trim ( ) . replace ( / \/ + $ / , "" ) ;
253+ if ( ! issuer ) return { status : "unavailable" } ;
254+ const token = readBearerToken ( request ) ;
255+ if ( ! token || ! isJwtLike ( token ) ) return { status : "unavailable" } ;
256+
257+ const headerDeviceId = request . headers . get ( "x-device-id" ) ?. trim ( ) ?? "" ;
258+ const response = await fetch ( `${ issuer } /userinfo` , {
259+ headers : {
260+ authorization : `Bearer ${ token } ` ,
261+ accept : "application/json"
262+ }
263+ } ) . catch ( ( ) => null ) ;
264+
265+ if ( ! response ?. ok ) return { status : "invalid" } ;
266+
267+ const payload = ( await response . json ( ) . catch ( ( ) => null ) ) as Partial < SsoUserInfo > | null ;
268+ const sub = typeof payload ?. sub === "string" ? payload . sub . trim ( ) : "" ;
269+ if ( ! sub ) return { status : "invalid" } ;
270+
271+ return {
272+ status : "ok" ,
273+ identity : {
274+ userId : sub ,
275+ deviceId : headerDeviceId || "web_browser"
276+ } ,
277+ user : {
278+ sub,
279+ tid : typeof payload ?. tid === "string" ? payload . tid : undefined ,
280+ gaid : typeof payload ?. gaid === "string" ? payload . gaid : undefined ,
281+ email : typeof payload ?. email === "string" ? payload . email : undefined
282+ }
283+ } ;
284+ } ;
285+
214286const hmacSign = async ( secret : string , message : string ) : Promise < string > => {
215287 const enc = new TextEncoder ( ) ;
216288 const key = await crypto . subtle . importKey (
@@ -311,8 +383,20 @@ const getDbOrError = (env: Env): D1Database | Response => {
311383} ;
312384
313385const getIdentity = async ( request : Request , env : Env ) : Promise < Identity | Response > => {
314- const session = ( await readSession ( request , env ) ) ?? ( await readBearerSession ( request , env ) ) ;
386+ const authMode = resolveAuthMode ( env ) ;
315387 const headerDeviceId = request . headers . get ( "x-device-id" ) ?. trim ( ) ?? "" ;
388+
389+ if ( authMode !== "legacy" ) {
390+ const sso = await readSsoIdentity ( request , env ) ;
391+ if ( sso . status === "ok" ) {
392+ return sso . identity ;
393+ }
394+ if ( authMode === "sso" ) {
395+ return fail ( "AUTH_REQUIRED" , "SSO sign-in is required." , 401 ) ;
396+ }
397+ }
398+
399+ const session = ( await readSession ( request , env ) ) ?? ( await readBearerSession ( request , env ) ) ;
316400 if ( session ) {
317401 return { userId : session . sub , deviceId : headerDeviceId || "web_browser" } ;
318402 }
@@ -326,7 +410,7 @@ const getIdentity = async (request: Request, env: Env): Promise<Identity | Respo
326410 if ( ! userId || ! deviceId ) {
327411 return fail (
328412 "IDENTITY_REQUIRED" ,
329- "Sign in with GitHub or provide headers x-user-id and x-device-id." ,
413+ "Sign in with SSO or provide headers x-user-id and x-device-id." ,
330414 400
331415 ) ;
332416 }
@@ -1683,10 +1767,25 @@ const getGithubRedirectUri = (request: Request, env: Env): string => {
16831767 return `${ url . origin } /v1/auth/github/callback` ;
16841768} ;
16851769
1770+ const getSsoIssuer = ( env : Env ) : string => String ( env . SSO_ISSUER ?? "" ) . trim ( ) . replace ( / \/ + $ / , "" ) ;
1771+
1772+ const getSsoClientId = ( env : Env ) : string => String ( env . SSO_CLIENT_ID ?? "misonote-paste-web" ) . trim ( ) || "misonote-paste-web" ;
1773+
1774+ const isHttpUrl = ( value : string ) : boolean => / ^ h t t p s ? : \/ \/ / i. test ( value . trim ( ) ) ;
1775+
16861776const hasGithubAuthConfig = ( env : Env ) : boolean =>
16871777 Boolean ( env . GITHUB_CLIENT_ID ?. trim ( ) && env . GITHUB_CLIENT_SECRET ?. trim ( ) && env . AUTH_SECRET ?. trim ( ) ) ;
16881778
1779+ const ensureGithubAuthEnabled = ( env : Env ) : Response | null => {
1780+ if ( resolveAuthMode ( env ) === "legacy" ) {
1781+ return null ;
1782+ }
1783+ return fail ( "AUTH_METHOD_DISABLED" , "GitHub auth is disabled. Use Cloudflare SSO." , 404 ) ;
1784+ } ;
1785+
16891786const handleAuthGithubStart = async ( request : Request , env : Env ) : Promise < Response > => {
1787+ const disabled = ensureGithubAuthEnabled ( env ) ;
1788+ if ( disabled ) return disabled ;
16901789 if ( ! hasGithubAuthConfig ( env ) ) {
16911790 return fail ( "AUTH_CONFIG_MISSING" , "GitHub auth is not configured." , 500 ) ;
16921791 }
@@ -1715,6 +1814,8 @@ const handleAuthGithubStart = async (request: Request, env: Env): Promise<Respon
17151814} ;
17161815
17171816const handleAuthGithubCallback = async ( request : Request , env : Env ) : Promise < Response > => {
1817+ const disabled = ensureGithubAuthEnabled ( env ) ;
1818+ if ( disabled ) return disabled ;
17181819 if ( ! hasGithubAuthConfig ( env ) ) {
17191820 return fail ( "AUTH_CONFIG_MISSING" , "GitHub auth is not configured." , 500 ) ;
17201821 }
@@ -1808,6 +1909,8 @@ const handleAuthGithubCallback = async (request: Request, env: Env): Promise<Res
18081909} ;
18091910
18101911const handleAuthGithubDeviceStart = async ( env : Env ) : Promise < Response > => {
1912+ const disabled = ensureGithubAuthEnabled ( env ) ;
1913+ if ( disabled ) return disabled ;
18111914 if ( ! hasGithubAuthConfig ( env ) ) {
18121915 return fail ( "AUTH_CONFIG_MISSING" , "GitHub auth is not configured." , 500 ) ;
18131916 }
@@ -1854,6 +1957,8 @@ const handleAuthGithubDeviceStart = async (env: Env): Promise<Response> => {
18541957} ;
18551958
18561959const handleAuthGithubDevicePoll = async ( request : Request , env : Env ) : Promise < Response > => {
1960+ const disabled = ensureGithubAuthEnabled ( env ) ;
1961+ if ( disabled ) return disabled ;
18571962 if ( ! hasGithubAuthConfig ( env ) ) {
18581963 return fail ( "AUTH_CONFIG_MISSING" , "GitHub auth is not configured." , 500 ) ;
18591964 }
@@ -1950,7 +2055,123 @@ const handleAuthGithubDevicePoll = async (request: Request, env: Env): Promise<R
19502055 } ) ;
19512056} ;
19522057
2058+ const handleAuthSsoToken = async ( request : Request , env : Env ) : Promise < Response > => {
2059+ const issuer = getSsoIssuer ( env ) ;
2060+ if ( ! issuer ) {
2061+ return fail ( "SSO_CONFIG_MISSING" , "SSO issuer is not configured." , 500 ) ;
2062+ }
2063+
2064+ const parsed = await parseJson < {
2065+ grantType ?: "authorization_code" | "refresh_token" ;
2066+ code ?: string ;
2067+ codeVerifier ?: string ;
2068+ redirectUri ?: string ;
2069+ refreshToken ?: string ;
2070+ } > ( request ) ;
2071+ if ( parsed instanceof Response ) {
2072+ return parsed ;
2073+ }
2074+
2075+ const grantType = parsed . grantType ?? "authorization_code" ;
2076+ if ( grantType !== "authorization_code" && grantType !== "refresh_token" ) {
2077+ return fail ( "INVALID_GRANT_TYPE" , "grantType must be authorization_code or refresh_token" , 400 ) ;
2078+ }
2079+
2080+ const form = new URLSearchParams ( ) ;
2081+ form . set ( "grant_type" , grantType ) ;
2082+ form . set ( "client_id" , getSsoClientId ( env ) ) ;
2083+
2084+ if ( grantType === "authorization_code" ) {
2085+ const code = String ( parsed . code ?? "" ) . trim ( ) ;
2086+ const codeVerifier = String ( parsed . codeVerifier ?? "" ) . trim ( ) ;
2087+ const redirectUri = String ( parsed . redirectUri ?? "" ) . trim ( ) ;
2088+ if ( ! code || ! codeVerifier || ! redirectUri ) {
2089+ return fail ( "INVALID_SSO_CODE_EXCHANGE" , "code, codeVerifier, redirectUri are required" , 400 ) ;
2090+ }
2091+ if ( ! isHttpUrl ( redirectUri ) ) {
2092+ return fail ( "INVALID_REDIRECT_URI" , "redirectUri must be http(s)" , 400 ) ;
2093+ }
2094+ form . set ( "code" , code ) ;
2095+ form . set ( "code_verifier" , codeVerifier ) ;
2096+ form . set ( "redirect_uri" , redirectUri ) ;
2097+ } else {
2098+ const refreshToken = String ( parsed . refreshToken ?? "" ) . trim ( ) ;
2099+ if ( ! refreshToken ) {
2100+ return fail ( "INVALID_REFRESH_TOKEN" , "refreshToken is required" , 400 ) ;
2101+ }
2102+ form . set ( "refresh_token" , refreshToken ) ;
2103+ }
2104+
2105+ const response = await fetch ( `${ issuer } /token` , {
2106+ method : "POST" ,
2107+ headers : {
2108+ accept : "application/json" ,
2109+ "content-type" : "application/x-www-form-urlencoded"
2110+ } ,
2111+ body : form . toString ( )
2112+ } ) ;
2113+
2114+ const payload = ( await response . json ( ) . catch ( ( ) => ( { } ) ) ) as {
2115+ access_token ?: string ;
2116+ refresh_token ?: string ;
2117+ expires_in ?: number ;
2118+ token_type ?: string ;
2119+ scope ?: string ;
2120+ error_description ?: string ;
2121+ statusMessage ?: string ;
2122+ } ;
2123+ if ( ! response . ok ) {
2124+ return fail (
2125+ "SSO_TOKEN_EXCHANGE_FAILED" ,
2126+ payload . statusMessage ?. trim ( ) || payload . error_description ?. trim ( ) || `sso token exchange failed (${ response . status } )` ,
2127+ response . status >= 400 && response . status < 600 ? response . status : 502
2128+ ) ;
2129+ }
2130+
2131+ const accessToken = String ( payload . access_token ?? "" ) . trim ( ) ;
2132+ if ( ! accessToken ) {
2133+ return fail ( "SSO_TOKEN_MISSING" , "sso access token missing" , 502 ) ;
2134+ }
2135+
2136+ return ok ( {
2137+ tokenType : payload . token_type || "Bearer" ,
2138+ accessToken,
2139+ refreshToken : String ( payload . refresh_token ?? "" ) . trim ( ) || null ,
2140+ expiresIn : Number . isFinite ( Number ( payload . expires_in ) ) ? Number ( payload . expires_in ) : 300 ,
2141+ scope : payload . scope || null
2142+ } ) ;
2143+ } ;
2144+
19532145const handleAuthMe = async ( request : Request , env : Env ) : Promise < Response > => {
2146+ const authMode = resolveAuthMode ( env ) ;
2147+ if ( authMode !== "legacy" ) {
2148+ const sso = await readSsoIdentity ( request , env ) ;
2149+ if ( sso . status === "ok" ) {
2150+ return ok ( {
2151+ authenticated : true ,
2152+ user : {
2153+ userId : sso . user . sub ,
2154+ githubLogin : sso . user . gaid ?? sso . user . sub ,
2155+ githubId : 0
2156+ } ,
2157+ headerIdentityEnabled : authMode !== "sso" && allowHeaderIdentity ( env ) ,
2158+ authConfigured : true ,
2159+ authMode,
2160+ authSource : "sso"
2161+ } ) ;
2162+ }
2163+ if ( authMode === "sso" ) {
2164+ return ok ( {
2165+ authenticated : false ,
2166+ user : null ,
2167+ headerIdentityEnabled : false ,
2168+ authConfigured : true ,
2169+ authMode,
2170+ authSource : null
2171+ } ) ;
2172+ }
2173+ }
2174+
19542175 const session = ( await readSession ( request , env ) ) ?? ( await readBearerSession ( request , env ) ) ;
19552176 return ok ( {
19562177 authenticated : Boolean ( session ) ,
@@ -1962,7 +2183,9 @@ const handleAuthMe = async (request: Request, env: Env): Promise<Response> => {
19622183 }
19632184 : null ,
19642185 headerIdentityEnabled : allowHeaderIdentity ( env ) ,
1965- authConfigured : hasGithubAuthConfig ( env )
2186+ authConfigured : hasGithubAuthConfig ( env ) ,
2187+ authMode,
2188+ authSource : session ? "legacy" : null
19662189 } ) ;
19672190} ;
19682191
@@ -2046,6 +2269,10 @@ export default {
20462269 return handleAuthGithubDevicePoll ( request , env ) ;
20472270 }
20482271
2272+ if ( request . method === "POST" && path === "/v1/auth/sso/token" ) {
2273+ return handleAuthSsoToken ( request , env ) ;
2274+ }
2275+
20492276 if ( request . method === "POST" && path === "/v1/auth/logout" ) {
20502277 return handleAuthLogout ( request ) ;
20512278 }
0 commit comments