diff --git a/packages/browser/test/index.test.ts b/packages/browser/test/index.test.ts index decb1a59..5191da31 100644 --- a/packages/browser/test/index.test.ts +++ b/packages/browser/test/index.test.ts @@ -13,10 +13,11 @@ describe('nile db', () => { if (k === 'auth') { expect(props).toEqual([ 'constructor', + 'createProvider', 'getSSOProviders', 'login', 'signUp', - 'updateSSOProvider', + 'updateProvider', ]); } if (k === 'users') { diff --git a/packages/react/src/SSO/BaseSSOForm.tsx b/packages/react/src/SSO/BaseSSOForm.tsx index 4f45f09e..cec91e40 100644 --- a/packages/react/src/SSO/BaseSSOForm.tsx +++ b/packages/react/src/SSO/BaseSSOForm.tsx @@ -1,14 +1,16 @@ import React from 'react'; import { useMutation } from '@tanstack/react-query'; -import { UpdateSSOProviderRequest } from '@theniledev/browser'; +import { UpdateProviderRequest } from '@theniledev/browser'; import SimpleForm from '../lib/SimpleForm'; import { useApi } from '../context'; import { Attribute, AttributeType } from '../lib/SimpleForm/types'; -import { Props } from './types'; +import { OktaProps } from './types'; -export default function BaseSSOForm(props: Props & { providerName: string }) { +export default function BaseSSOForm( + props: OktaProps & { providerName: string } +) { const api = useApi(); const { config, providerName, onSuccess, onError, allowEdit = true } = props; const attributes = React.useMemo(() => { @@ -26,6 +28,8 @@ export default function BaseSSOForm(props: Props & { providerName: string }) { label: 'Config url', type: AttributeType.Text, defaultValue: config?.configUrl ?? '', + helpText: + 'The URL of the .well-known/openid-configuration for the identity provider', required: true, disabled: !allowEdit, }, @@ -33,7 +37,8 @@ export default function BaseSSOForm(props: Props & { providerName: string }) { name: 'redirectURI', label: 'Redirect URI', type: AttributeType.Text, - helpText: 'Where users should be redirected to upon login', + helpText: + 'Where users should be redirected to after a successful login', defaultValue: config?.redirectURI ?? '', required: true, disabled: !allowEdit, @@ -44,7 +49,8 @@ export default function BaseSSOForm(props: Props & { providerName: string }) { type: AttributeType.Text, defaultValue: config?.emailDomains?.join(', ') ?? '', required: true, - helpText: 'A comma seperated list of email domains to be used', + helpText: + 'A comma seperated list of email domains (@yourDomain.com) to be used', disabled: !allowEdit, }, ]; @@ -68,11 +74,19 @@ export default function BaseSSOForm(props: Props & { providerName: string }) { ]); const mutation = useMutation( - (ssoRequest: UpdateSSOProviderRequest) => { - return api.auth.updateSSOProvider({ + (ssoRequest: UpdateProviderRequest & { emailDomains: string }) => { + const payload = { providerName: providerName.toLowerCase(), - updateSSOProviderRequest: ssoRequest, - }); + updateProviderRequest: { + ...ssoRequest, + emailDomains: ssoRequest.emailDomains.split(','), + }, + }; + if (config != null) { + return api.auth.updateProvider(payload); + } else { + return api.auth.createProvider(payload); + } }, { onSuccess, diff --git a/packages/react/src/SSO/Okta.tsx b/packages/react/src/SSO/Okta.tsx index b59bc8dc..c97ab75e 100644 --- a/packages/react/src/SSO/Okta.tsx +++ b/packages/react/src/SSO/Okta.tsx @@ -1,8 +1,8 @@ import React from 'react'; import BaseSSOForm from './BaseSSOForm'; -import { Props } from './types'; +import { OktaProps } from './types'; -export default function Okta(props: Props) { +export default function Okta(props: OktaProps) { return ; } diff --git a/packages/react/src/SSO/index.ts b/packages/react/src/SSO/index.ts index 98402daa..adfa6200 100644 --- a/packages/react/src/SSO/index.ts +++ b/packages/react/src/SSO/index.ts @@ -1,2 +1,3 @@ export { default as Okta } from './Okta'; export { default } from './BaseSSOForm'; +export * from './types'; diff --git a/packages/react/src/SSO/types.ts b/packages/react/src/SSO/types.ts index c8612035..bce5c92c 100644 --- a/packages/react/src/SSO/types.ts +++ b/packages/react/src/SSO/types.ts @@ -1,7 +1,7 @@ -import { GetSSOProvider200Response } from '@theniledev/browser'; +import { GetSSOProviders200Response } from '@theniledev/browser'; -export type Props = { - config?: GetSSOProvider200Response; +export type OktaProps = { + config?: GetSSOProviders200Response; onSuccess?: (data: unknown, variables: unknown) => void; onError?: (e: Error) => void; allowEdit?: boolean; diff --git a/packages/react/test/SSO/Okta.test.tsx b/packages/react/test/SSO/Okta.test.tsx index b0f583a1..eebade88 100644 --- a/packages/react/test/SSO/Okta.test.tsx +++ b/packages/react/test/SSO/Okta.test.tsx @@ -13,7 +13,7 @@ describe('Okta', () => { global.fetch = token; const api = { auth: { - updateSSOProvider: async () => jest.fn(), + createProvider: async () => jest.fn(), }, } as unknown as Client; render( diff --git a/packages/server/openapi/index.json b/packages/server/openapi/index.json index bdbb584b..55b04cc8 100644 --- a/packages/server/openapi/index.json +++ b/packages/server/openapi/index.json @@ -37,6 +37,9 @@ }, "put": { "$ref": "../src/auth/providers/openapi/paths/updateProvider.json" + }, + "post": { + "$ref": "../src/auth/providers/openapi/paths/createProvider.json" } } }, diff --git a/packages/server/openapi/spec.json b/packages/server/openapi/spec.json index 41a6d2a4..8bd32916 100644 --- a/packages/server/openapi/spec.json +++ b/packages/server/openapi/spec.json @@ -990,7 +990,252 @@ ], "summary": "Update SSO provider", "description": "Update SSO provider by name", - "operationId": "updateSSOProvider", + "operationId": "updateProvider", + "parameters": [ + { + "name": "providerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "required": [ + "clientId", + "clientSecret", + "configUrl", + "emailDomains", + "redirectURI" + ], + "type": "object", + "properties": { + "configUrl": { + "type": "string", + "format": "uri" + }, + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "redirectURI": { + "type": "string", + "format": "uri" + }, + "emailDomains": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + } + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated OIDC provider", + "content": { + "application/json": { + "schema": { + "required": [ + "clientId", + "configUrl", + "provider", + "redirectURI", + "tenantId" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "readOnly": true + }, + "tenantId": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "configUrl": { + "type": "string", + "format": "uri" + }, + "clientId": { + "type": "string" + }, + "redirectURI": { + "type": "string", + "format": "uri" + }, + "emailDomains": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "OIDC provider name mismatch", + "content": { + "application/json": { + "schema": { + "required": [ + "errorCode", + "message", + "statusCode" + ], + "type": "object", + "additionalProperties": true, + "properties": { + "errorCode": { + "type": "string", + "enum": [ + "internal_error", + "bad_request", + "entity_not_found", + "duplicate_entity", + "invalid_credentials", + "unknown_oidc_provider", + "provider_already_exists", + "provider_config_error", + "provider_mismatch", + "provider_update_error", + "session_state_missing", + "session_state_mismatch", + "oidc_code_missing" + ], + "message": { + "type": "string" + }, + "statusCode": { + "type": "integer", + "format": "int32" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "required": [ + "errorCode", + "message", + "statusCode" + ], + "type": "object", + "additionalProperties": true, + "properties": { + "errorCode": { + "type": "string", + "enum": [ + "internal_error", + "bad_request", + "entity_not_found", + "duplicate_entity", + "invalid_credentials", + "unknown_oidc_provider", + "provider_already_exists", + "provider_config_error", + "provider_mismatch", + "provider_update_error", + "session_state_missing", + "session_state_mismatch", + "oidc_code_missing" + ], + "message": { + "type": "string" + }, + "statusCode": { + "type": "integer", + "format": "int32" + } + } + } + } + } + } + }, + "404": { + "description": "OIDC provider not found", + "content": { + "application/json": { + "schema": { + "required": [ + "errorCode", + "message", + "statusCode" + ], + "type": "object", + "additionalProperties": true, + "properties": { + "errorCode": { + "type": "string", + "enum": [ + "internal_error", + "bad_request", + "entity_not_found", + "duplicate_entity", + "invalid_credentials", + "unknown_oidc_provider", + "provider_already_exists", + "provider_config_error", + "provider_mismatch", + "provider_update_error", + "session_state_missing", + "session_state_mismatch", + "oidc_code_missing" + ], + "message": { + "type": "string" + }, + "statusCode": { + "type": "integer", + "format": "int32" + } + } + } + } + } + } + } + }, + "security": [ + { + "jwtBearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "authentication" + ], + "summary": "Create SSO provider", + "description": "Create an SSO provider by name", + "operationId": "createProvider", "parameters": [ { "name": "providerName", diff --git a/packages/server/src/auth/auth.test.ts b/packages/server/src/auth/auth.test.ts index 3bd64ecf..8113d6c2 100644 --- a/packages/server/src/auth/auth.test.ts +++ b/packages/server/src/auth/auth.test.ts @@ -5,10 +5,12 @@ import Auth from './'; const baseConfig = [ '_tenantId', 'api', + 'createProvider', 'database', 'workspace', 'db', - 'getProviders', + 'listProviders', + 'listTenantProviders', 'login', 'loginSSO', 'loginSSOUrl', diff --git a/packages/server/src/auth/index.ts b/packages/server/src/auth/index.ts index b82d8bd1..f7b7c4ea 100644 --- a/packages/server/src/auth/index.ts +++ b/packages/server/src/auth/index.ts @@ -30,7 +30,7 @@ export default class Auth extends Config { const sso = params.get('sso'); if (sso) { - const providerRes = await this.getProviders( + const providerRes = await this.listProviders( (req as Request).clone(), init ); @@ -49,13 +49,13 @@ export default class Auth extends Config { // is there a way to do this? probably not. headers.set(X_NILE_TENANT, providers[0].tenantId); - const ssoResp = await new Response( - ( - await this.loginSSO(req) - ).body - ).json(); - const redirectUrl = ssoResp.uri; - return Response.redirect(redirectUrl, 302); + headers.append( + 'set-cookie', + `tenantId=${providers[0].tenantId}; path=/; httponly;` + ); + await this.loginSSO(req); + // make it a client side redirect, because of the headers + // return Response.redirect(redirectUrl, 302); // if there is no provider, require a password. } } @@ -69,18 +69,16 @@ export default class Auth extends Config { if (res instanceof ResponseError) { return res.response; } - if (res && res.status >= 200 && res.status < 300) { const token: RestModels.LoginUserResponse = await res.json(); const cookie = `${this.api?.cookieKey}=${token.token.jwt}; path=/; samesite=lax; httponly;`; - headers.set('set-cookie', cookie); - const hasTenantId = headers.get(X_NILE_TENANT); - if (!hasTenantId) { - const { tenants } = token; - const tenant = tenants?.values(); - const tenantId = tenant?.next().value; - headers.set(X_NILE_TENANT, tenantId); - } + headers.append('set-cookie', cookie); + // const hasTenantId = headers.get(X_NILE_TENANT); + const { tenants } = token; + const tenant = tenants?.values(); + const tenantId = tenant?.next().value; + headers.set(X_NILE_TENANT, tenantId); + headers.append('set-cookie', `tenantId=${tenantId}; path=/; httponly;`); return new Response(JSON.stringify(token), { status: 200, headers }); } const text = await res.text(); @@ -125,7 +123,23 @@ export default class Auth extends Config { }/auth/oidc/providers/${encodeURIComponent(providerName)}`; } - updateProvider = async ( + get listTenantProvidersUrl() { + return `/workspaces/${encodeURIComponent( + this.workspace + )}/databases/${encodeURIComponent(this.database)}/tenants/${ + this.tenantId ? encodeURIComponent(this.tenantId) : '{tenantId}' + }/auth/oidc/providers`; + } + + listTenantProviders = async ( + req: NileRequest, + init?: RequestInit + ): NileResponse => { + const _requester = new Requester(this); + return _requester.get(req, this.listTenantProvidersUrl, init); + }; + + createProvider = async ( req: NileRequest, init?: RequestInit ): NileResponse => { @@ -134,6 +148,15 @@ export default class Auth extends Config { return _requester.post(req, this.updateProviderUrl(providerName), init); }; + updateProvider = async ( + req: NileRequest, + init?: RequestInit + ): NileResponse => { + const _requester = new Requester(this); + const providerName = 'okta'; + return _requester.put(req, this.updateProviderUrl(providerName), init); + }; + providerUrl(email?: undefined | string) { return `/workspaces/${encodeURIComponent( this.workspace @@ -143,10 +166,11 @@ export default class Auth extends Config { email ? `?email=${encodeURIComponent(email)}` : '' }`; } - getProviders = async ( + + listProviders = async ( req: NileRequest, init?: RequestInit - ): NileResponse => { + ): NileResponse => { const _requester = new Requester(this); let body: { email: string } | undefined; // this is a get. Get the email from the response body so the request is filtered. diff --git a/packages/server/src/auth/login/login.test.ts b/packages/server/src/auth/login/login.test.ts index 2046d044..ea67416e 100644 --- a/packages/server/src/auth/login/login.test.ts +++ b/packages/server/src/auth/login/login.test.ts @@ -13,14 +13,27 @@ describe('login', () => { global.Response = FakeResponse; //@ts-expect-error - test global.Request = FakeRequest; - global.fetch = _fetch({ token: { jwt: 'adfasdfdsa' } }); + global.fetch = _fetch({ + token: { jwt: 'adfasdfdsa' }, + tenants: { + values: () => { + return { + next: () => ({ + value: 'adfdsafdsf', + }), + }; + }, + }, + }); const _config = new Config(config); const { login } = new Auth(_config); const params = { email: 'email', password: 'password' }; const resp = await login(params); const headers = new Headers(resp.headers); const cookie = headers.get('set-cookie'); - expect(cookie).toEqual('token=adfasdfdsa; path=/; samesite=lax; httponly;'); + expect(cookie).toEqual( + 'token=adfasdfdsa; path=/; samesite=lax; httponly;, tenantId=adfdsafdsf; path=/; httponly;' + ); }); it('goes to the right url', () => { diff --git a/packages/server/src/auth/providers/openapi/paths/createProvider.json b/packages/server/src/auth/providers/openapi/paths/createProvider.json new file mode 100644 index 00000000..9e933e1d --- /dev/null +++ b/packages/server/src/auth/providers/openapi/paths/createProvider.json @@ -0,0 +1,73 @@ +{ + "tags": ["authentication"], + "summary": "Create SSO provider", + "description": "Create an SSO provider by name", + "operationId": "createProvider", + "parameters": [ + { + "name": "providerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "../../../../../openapi/index.json#/components/schemas/RegisterSSO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated OIDC provider", + "content": { + "application/json": { + "schema": { + "$ref": "../../../../../openapi/index.json#/components/schemas/SSOProvider" + } + } + } + }, + "400": { + "description": "OIDC provider name mismatch", + "content": { + "application/json": { + "schema": { + "$ref": "../../../../../openapi/index.json#/components/schemas/APIError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "../../../../../openapi/index.json#/components/schemas/APIError" + } + } + } + }, + "404": { + "description": "OIDC provider not found", + "content": { + "application/json": { + "schema": { + "$ref": "../../../../../openapi/index.json#/components/schemas/APIError" + } + } + } + } + }, + "security": [ + { + "jwtBearerAuth": [] + } + ] +} diff --git a/packages/server/src/auth/providers/openapi/paths/updateProvider.json b/packages/server/src/auth/providers/openapi/paths/updateProvider.json index 630396f1..f509ad92 100644 --- a/packages/server/src/auth/providers/openapi/paths/updateProvider.json +++ b/packages/server/src/auth/providers/openapi/paths/updateProvider.json @@ -2,7 +2,7 @@ "tags": ["authentication"], "summary": "Update SSO provider", "description": "Update SSO provider by name", - "operationId": "updateSSOProvider", + "operationId": "updateProvider", "parameters": [ { "name": "providerName", diff --git a/packages/server/src/auth/providers/providers.test.ts b/packages/server/src/auth/providers/providers.test.ts index 13b38f3b..4824b30b 100644 --- a/packages/server/src/auth/providers/providers.test.ts +++ b/packages/server/src/auth/providers/providers.test.ts @@ -11,7 +11,7 @@ const config = { database: 'database', tenantId: 'tenantId', }; -describe('getProviders', () => { +describe('listProviders', () => { it('does a get', async () => { //@ts-expect-error - test global.Response = FakeResponse; @@ -19,9 +19,9 @@ describe('getProviders', () => { global.Request = FakeRequest; global.fetch = _fetch(); const _config = new Config(config); - const { getProviders } = new Auth(_config); + const { listProviders } = new Auth(_config); - const res = await getProviders(); + const res = await listProviders(); //@ts-expect-error - test expect(res.config).toEqual( _config.api.basePath + diff --git a/packages/server/src/utils/Requester/index.ts b/packages/server/src/utils/Requester/index.ts index 7b9071f4..b66245d2 100644 --- a/packages/server/src/utils/Requester/index.ts +++ b/packages/server/src/utils/Requester/index.ts @@ -55,16 +55,24 @@ export default class Requester extends Config { const headers = new Headers(init ? init?.headers : {}); if (req instanceof Headers) { const tenantId = req.get(X_NILE_TENANT); + const cookie = req.get('cookie'); if (tenantId) { headers.set(X_NILE_TENANT, tenantId); } + if (cookie) { + headers.set('cookie', cookie); + } } else if (req instanceof Request) { // pass back the X_NILE_TENANT const _headers = new Headers(req?.headers); const tenantId = _headers.get(X_NILE_TENANT); + const cookie = _headers.get('cookie'); if (tenantId) { headers.set(X_NILE_TENANT, tenantId); } + if (cookie) { + headers.set('cookie', cookie); + } } // default the body - may be the actual payload for the API let body: string | undefined = JSON.stringify(req); diff --git a/packages/server/src/utils/fetch.ts b/packages/server/src/utils/fetch.ts index d98813e6..3aa65d4b 100644 --- a/packages/server/src/utils/fetch.ts +++ b/packages/server/src/utils/fetch.ts @@ -54,18 +54,17 @@ export async function _fetch( basicHeaders.set('content-type', 'application/json; charset=utf-8'); const authHeader = headers.get('Authorization'); if (!authHeader) { - if (config.api?.token) { - basicHeaders.set('Authorization', `Bearer ${config.api?.token}`); + const token = getTokenFromCookie(headers, cookieKey); + if (token) { + basicHeaders.set('Authorization', `Bearer ${token}`); } else { - const token = getTokenFromCookie(headers, cookieKey); - - if (token) { - basicHeaders.set('Authorization', `Bearer ${token}`); - } + basicHeaders.set('Authorization', `Bearer ${config.api?.token}`); } } - const tenantId = config.tenantId ?? headers?.get(X_NILE_TENANT); + const cookieTenant = getTokenFromCookie(headers, 'tenantId'); + const tenantId = + cookieTenant ?? headers?.get(X_NILE_TENANT) ?? config.tenantId; updateTenantId(tenantId); if (url.includes('{tenantId}') && !tenantId) { diff --git a/packages/server/test/fetch.mock.ts b/packages/server/test/fetch.mock.ts index 5f6176ca..0f36ff67 100644 --- a/packages/server/test/fetch.mock.ts +++ b/packages/server/test/fetch.mock.ts @@ -5,22 +5,27 @@ type Something = any; export class FakeResponse { [key: string]: Something; - payload: string; + payload: object | string; headers?: Headers; - constructor(payload: string, config?: RequestInit) { + constructor(payload: object | string, config?: RequestInit) { this.payload = payload; if (config) { this.headers = new Headers(config.headers); } + let pload = payload; if (typeof payload === 'string') { - const pload = JSON.parse(payload); - Object.keys(pload).map((key) => { - this[key] = pload[key]; - }); + pload = JSON.parse(payload); } + + Object.keys(pload).map((key) => { + this[key] = (pload as Record)[key]; + }); } json = async () => { - return JSON.parse(this.payload); + if (typeof this.payload === 'string') { + return JSON.parse(this.payload); + } + return this.payload; }; text = async () => { return this.payload; @@ -42,13 +47,11 @@ export class FakeRequest { export const _fetch = (payload?: Record) => (async (config: Config, path: string, opts?: RequestInit) => { - return new FakeResponse( - JSON.stringify({ - ...payload, - config, - path, - opts, - status: 200, - }) - ); + return new FakeResponse({ + ...payload, + config, + path, + opts, + status: 200, + }); }) as unknown as typeof fetch;