diff --git a/apps/backend/src/oauth/index.tsx b/apps/backend/src/oauth/index.tsx index d7165a2c11..a236d6b9b0 100644 --- a/apps/backend/src/oauth/index.tsx +++ b/apps/backend/src/oauth/index.tsx @@ -14,6 +14,7 @@ import { GoogleProvider } from "./providers/google"; import { LinkedInProvider } from "./providers/linkedin"; import { MicrosoftProvider } from "./providers/microsoft"; import { MockProvider } from "./providers/mock"; +import { OktaProvider } from "./providers/okta"; import { SpotifyProvider } from "./providers/spotify"; import { TwitchProvider } from "./providers/twitch"; import { XProvider } from "./providers/x"; @@ -31,6 +32,7 @@ const _providers = { linkedin: LinkedInProvider, x: XProvider, twitch: TwitchProvider, + okta: OktaProvider, } as const; const mockProvider = MockProvider; @@ -78,6 +80,7 @@ export async function getProvider(provider: Tenancy['config']['auth']['oauth'][' clientSecret: provider.clientSecret || throwErr("Client secret is required for standard providers"), facebookConfigId: provider.facebookConfigId, microsoftTenantId: provider.microsoftTenantId, + oktaDomain: provider.oktaDomain, }); } } diff --git a/apps/backend/src/oauth/providers/okta.tsx b/apps/backend/src/oauth/providers/okta.tsx new file mode 100644 index 0000000000..c1eaea341f --- /dev/null +++ b/apps/backend/src/oauth/providers/okta.tsx @@ -0,0 +1,79 @@ +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { getJwtInfo } from "@stackframe/stack-shared/dist/utils/jwt"; +import { OAuthUserInfo, validateUserInfo } from "../utils"; +import { OAuthBaseProvider, TokenSet } from "./base"; + + +export class OktaProvider extends OAuthBaseProvider { + private oktaDomain : string; + private constructor( + oktaDomain: string, + ...args: ConstructorParameters + ) { + super(...args); + this.oktaDomain = oktaDomain + } + + static async create(options: { + clientId: string; + clientSecret: string; + oktaDomain: string; + }) { + const oktaDomain = options.oktaDomain; + + if(!oktaDomain) throw new StackAssertionError("Okta domain is required ") + + return new OktaProvider( + oktaDomain, + ...await OAuthBaseProvider.createConstructorArgs({ + issuer: `https://${oktaDomain}`, + authorizationEndpoint: `https://${oktaDomain}/v1/authorize`, + tokenEndpoint: `https://${oktaDomain}/v1/token`, + redirectUri: getEnvVariable("OAUTH_REDIRECT_URI")!, + jwksUri: `https://${oktaDomain}/v1/keys`, + baseScope: "openid email profile", + authorizationExtraParams: { response_mode: "form_post" }, + tokenEndpointAuthMethod: "client_secret_basic", + ...options, + })) + ; + } + + async postProcessUserInfo(tokenSet: TokenSet): Promise { + const rawUserInfoRes = await fetch(`https://${this.oktaDomain}/v1/userinfo`,{ + headers: { + Authorization: `Bearer ${tokenSet.accessToken}`, + }, + }); + + if (!rawUserInfoRes.ok) { + throw new StackAssertionError( + "Error fetching user information from Okta", + { + status: rawUserInfoRes.status, + body: await rawUserInfoRes.text(), + jwtInfo: await getJwtInfo({ jwt: tokenSet.accessToken }), + } + ); + } + + const rawUserInfo = await rawUserInfoRes.json(); + + return validateUserInfo({ + accountId: rawUserInfo.sub, + displayName: rawUserInfo.name, + profileImageUrl: rawUserInfo.picture, + email: rawUserInfo.email, + emailVerified: rawUserInfo.email_verified, + }); + } + async checkAccessTokenValidity(accessToken: string): Promise { + const res = await fetch(`https://${this.oktaDomain}/v1/userinfo`,{ + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return res.ok; + } +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx index 64098f8ed6..b73e75c141 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx @@ -44,6 +44,7 @@ function toTitle(id: string) { linkedin: "LinkedIn", twitch: "Twitch", x: "X", + okta: "Okta" }[id]; } @@ -63,6 +64,7 @@ export const providerFormSchema = yupObject({ }), facebookConfigId: yupString().optional(), microsoftTenantId: yupString().optional(), + oktaDomain: yupString().optional() }); export type ProviderFormValues = yup.InferType @@ -75,6 +77,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( clientSecret: (props.provider as any)?.clientSecret ?? "", facebookConfigId: (props.provider as any)?.facebookConfigId ?? "", microsoftTenantId: (props.provider as any)?.microsoftTenantId ?? "", + oktaDomain:(props.provider as any)?.oktaDomain?? "", }; const onSubmit = async (values: ProviderFormValues) => { @@ -88,6 +91,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( clientSecret: values.clientSecret || "", facebookConfigId: values.facebookConfigId, microsoftTenantId: values.microsoftTenantId, + oktaDomain: values.oktaDomain, }); } }; @@ -166,6 +170,15 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( placeholder="Tenant ID" /> )} + + {props.id === 'Okta' && ( + + )} )} diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts index d1f796a499..5cb6d70f0d 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts @@ -375,8 +375,7 @@ it("returns an error when the oauth config is misconfigured", async ({ expect }) expect(invalidTypeResponse).toMatchInlineSnapshot(` NiceResponse { "status": 400, - "body": "auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch", - "headers": Headers {