mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(auth): implement cross-subdomain SSO for all web apps
Add Single Sign-On (SSO) support across all mana.how subdomains: - Add trySSO() method to @manacore/shared-auth that exchanges session cookies for JWT tokens - Add /api/v1/auth/session-to-token endpoint to mana-core-auth service - Update all 15 web apps to try SSO during auth initialization SSO Flow: 1. User logs in on any app (e.g., calendar.mana.how) 2. Session cookie is set with Domain=.mana.how 3. When visiting another app (e.g., todo.mana.how), it checks for local tokens first 4. If no local tokens, tries SSO via session cookie 5. Session cookie is exchanged for JWT tokens via new endpoint 6. User is automatically authenticated Apps updated: calendar, chat, clock, contacts, manacore, manadeck, nutriphi, picture, planta, presi, questions, skilltree, storage, todo, zitare Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
352070fb2f
commit
feaf27dd14
19 changed files with 491 additions and 16 deletions
|
|
@ -76,6 +76,7 @@ export const authStore = {
|
|||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Also tries SSO if no local tokens exist (cross-domain authentication)
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
|
@ -89,7 +90,19 @@ export const authStore = {
|
|||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
// First, check if we have valid local tokens
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export const authStore = {
|
|||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Also tries SSO if no local tokens exist (cross-domain authentication)
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
|
@ -89,7 +90,19 @@ export const authStore = {
|
|||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
// First, check if we have valid local tokens
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export const authStore = {
|
|||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Also tries SSO if no local tokens exist (cross-domain authentication)
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
|
@ -88,7 +89,19 @@ export const authStore = {
|
|||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
// First, check if we have valid local tokens
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export const authStore = {
|
|||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Also tries SSO if no local tokens exist (cross-domain authentication)
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
|
@ -89,7 +90,19 @@ export const authStore = {
|
|||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
// First, check if we have valid local tokens
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export const authStore = {
|
|||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Also tries SSO if no local tokens exist (cross-domain authentication)
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
|
@ -76,7 +77,19 @@ export const authStore = {
|
|||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
// First, check if we have valid local tokens
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
|
|
|||
|
|
@ -32,11 +32,24 @@ export const authStore = {
|
|||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Also tries SSO if no local tokens exist (cross-domain authentication)
|
||||
*/
|
||||
async initialize() {
|
||||
loading = true;
|
||||
try {
|
||||
const isAuth = await authService.isAuthenticated();
|
||||
// First, check if we have valid local tokens
|
||||
let isAuth = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!isAuth) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
isAuth = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isAuth) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = toManaUser(userData);
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export const authStore = {
|
|||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Also tries SSO if no local tokens exist (cross-domain authentication)
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
|
@ -76,7 +77,19 @@ export const authStore = {
|
|||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
// First, check if we have valid local tokens
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
|
|
|||
|
|
@ -77,8 +77,23 @@ export const authStore = {
|
|||
try {
|
||||
const authService = await getAuthService();
|
||||
if (authService) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
// First, check if we have valid local tokens
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error);
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ export const authStore = {
|
|||
return initialized;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Also tries SSO if no local tokens exist (cross-domain authentication)
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
|
|
@ -81,7 +85,19 @@ export const authStore = {
|
|||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
// First, check if we have valid local tokens
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const auth = {
|
|||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Also tries SSO if no local tokens exist (cross-domain authentication)
|
||||
*/
|
||||
async init() {
|
||||
if (initialized) return;
|
||||
|
|
@ -60,7 +61,19 @@ export const auth = {
|
|||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
// First, check if we have valid local tokens
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
|
|
|||
|
|
@ -65,6 +65,10 @@ export const authStore = {
|
|||
return initialized;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Also tries SSO if no local tokens exist (cross-domain authentication)
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
|
|
@ -77,7 +81,19 @@ export const authStore = {
|
|||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
// First, check if we have valid local tokens
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ export const authStore = {
|
|||
return initialized;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Also tries SSO if no local tokens exist (cross-domain authentication)
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
|
|
@ -82,7 +86,19 @@ export const authStore = {
|
|||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
// First, check if we have valid local tokens
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ export const authStore = {
|
|||
return initialized;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Also tries SSO if no local tokens exist (cross-domain authentication)
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
|
|
@ -51,7 +55,19 @@ export const authStore = {
|
|||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
// First, check if we have valid local tokens
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export const authStore = {
|
|||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Also tries SSO if no local tokens exist (cross-domain authentication)
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
|
@ -96,7 +97,19 @@ export const authStore = {
|
|||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
// First, check if we have valid local tokens
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export const authStore = {
|
|||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Also tries SSO if no local tokens exist (cross-domain authentication)
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
|
@ -89,7 +90,19 @@ export const authStore = {
|
|||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
// First, check if we have valid local tokens
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
// If not authenticated locally, try SSO (shared session cookie)
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful, user authenticated via shared session');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ const DEFAULT_ENDPOINTS: AuthEndpoints = {
|
|||
googleSignIn: '/api/v1/auth/google-signin',
|
||||
appleSignIn: '/api/v1/auth/apple-signin',
|
||||
credits: '/api/v1/credits/balance',
|
||||
// Better Auth native endpoints for SSO
|
||||
getSession: '/api/auth/get-session',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -613,6 +615,90 @@ export function createAuthService(config: AuthServiceConfig) {
|
|||
getStorageKeys(): StorageKeys {
|
||||
return storageKeys;
|
||||
},
|
||||
|
||||
/**
|
||||
* Try to authenticate using SSO session cookie
|
||||
*
|
||||
* This enables cross-domain SSO: If the user is logged in on another app
|
||||
* (e.g., calendar.mana.how), they will automatically be logged in here
|
||||
* via the shared session cookie on .mana.how
|
||||
*
|
||||
* @returns AuthResult with success=true if SSO succeeded
|
||||
*/
|
||||
async trySSO(): Promise<AuthResult> {
|
||||
try {
|
||||
const storage = getStorageAdapter();
|
||||
|
||||
// Check if we already have valid tokens - skip SSO if so
|
||||
const existingToken = await storage.getItem<string>(storageKeys.APP_TOKEN);
|
||||
if (existingToken && isTokenValidLocally(existingToken)) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Try to get session from cookie (credentials: 'include' sends cookies)
|
||||
const response = await fetch(`${baseUrl}${endpoints.getSession}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include', // Send cookies cross-origin
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// No valid session cookie - user needs to login manually
|
||||
return { success: false, error: 'No SSO session found' };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Better Auth returns session with user info
|
||||
if (!data.session || !data.user) {
|
||||
return { success: false, error: 'Invalid session response' };
|
||||
}
|
||||
|
||||
// Now get tokens by signing in with the session
|
||||
// We need to exchange the session for JWT tokens
|
||||
const tokenResponse = await fetch(`${baseUrl}/api/v1/auth/session-to-token`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
// Fallback: Session exists but no token endpoint
|
||||
// Store session info for display, but user may need to re-authenticate for API calls
|
||||
console.warn('SSO: Session found but token exchange not available');
|
||||
return { success: false, error: 'Token exchange not available' };
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
const appToken = tokenData.accessToken;
|
||||
const refreshToken = tokenData.refreshToken;
|
||||
|
||||
if (!appToken || !refreshToken) {
|
||||
return { success: false, error: 'Invalid token response' };
|
||||
}
|
||||
|
||||
// Store the tokens
|
||||
await Promise.all([
|
||||
storage.setItem(storageKeys.APP_TOKEN, appToken),
|
||||
storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
|
||||
storage.setItem(storageKeys.USER_EMAIL, data.user.email || ''),
|
||||
]);
|
||||
|
||||
console.log('SSO: Successfully authenticated via session cookie');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
// SSO failed - this is expected if user hasn't logged in anywhere
|
||||
console.debug('SSO check failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'SSO check failed',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return service;
|
||||
|
|
|
|||
|
|
@ -133,6 +133,8 @@ export interface AuthEndpoints {
|
|||
googleSignIn: string;
|
||||
appleSignIn: string;
|
||||
credits: string;
|
||||
/** Better Auth native endpoint for SSO session check */
|
||||
getSession: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import {
|
|||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
|
|
@ -181,6 +184,51 @@ export class AuthController {
|
|||
return this.betterAuthService.validateToken(body.token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange session cookie for JWT tokens (SSO)
|
||||
*
|
||||
* This endpoint enables cross-domain Single Sign-On (SSO).
|
||||
* If the user has a valid session cookie (from logging in on another app),
|
||||
* this returns JWT tokens that the app can use for API calls.
|
||||
*
|
||||
* The session cookie is set on .mana.how domain, so it's shared across:
|
||||
* - calendar.mana.how
|
||||
* - todo.mana.how
|
||||
* - contacts.mana.how
|
||||
* - etc.
|
||||
*/
|
||||
@Post('session-to-token')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Exchange session cookie for JWT tokens',
|
||||
description:
|
||||
'SSO endpoint: If user has a valid session cookie, returns JWT access and refresh tokens.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Tokens generated successfully',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
},
|
||||
accessToken: { type: 'string' },
|
||||
refreshToken: { type: 'string' },
|
||||
expiresIn: { type: 'number', example: 900 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'No valid session cookie' })
|
||||
async sessionToToken(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
return this.betterAuthService.sessionToToken(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWKS (JSON Web Key Set)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1345,4 +1345,134 @@ export class BetterAuthService {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SSO Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Exchange session cookie for JWT tokens (SSO)
|
||||
*
|
||||
* This enables cross-domain Single Sign-On. When a user is logged in
|
||||
* on one app (e.g., calendar.mana.how), they have a session cookie on
|
||||
* .mana.how domain. This method allows other apps to exchange that
|
||||
* cookie for JWT tokens they can use for API calls.
|
||||
*
|
||||
* @param req - Express request with cookies
|
||||
* @param res - Express response for setting headers
|
||||
* @returns JWT tokens or throws UnauthorizedException
|
||||
*/
|
||||
async sessionToToken(
|
||||
req: import('express').Request,
|
||||
res: import('express').Response
|
||||
): Promise<SignInResult> {
|
||||
try {
|
||||
// Get session cookie name (Better Auth uses this format with our prefix)
|
||||
const cookiePrefix = process.env.COOKIE_DOMAIN ? 'mana' : 'better-auth';
|
||||
const sessionCookieName = `__Secure-${cookiePrefix}.session_token`;
|
||||
const fallbackCookieName = `${cookiePrefix}.session_token`;
|
||||
|
||||
// Try to get session token from cookies
|
||||
const sessionToken = req.cookies?.[sessionCookieName] || req.cookies?.[fallbackCookieName];
|
||||
|
||||
if (!sessionToken) {
|
||||
this.logger.debug('SSO: No session cookie found', {
|
||||
cookies: Object.keys(req.cookies || {}),
|
||||
});
|
||||
throw new UnauthorizedException('No session cookie found');
|
||||
}
|
||||
|
||||
this.logger.debug('SSO: Found session cookie, validating...');
|
||||
|
||||
// Use Better Auth's getSession to validate the cookie
|
||||
// We need to create a Request object that Better Auth can process
|
||||
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
|
||||
const url = new URL('/api/auth/get-session', baseUrl);
|
||||
|
||||
const headers = new Headers({
|
||||
Cookie: `${sessionCookieName}=${sessionToken}`,
|
||||
});
|
||||
|
||||
const fetchRequest = new Request(url.toString(), {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
const response = await this.auth.handler(fetchRequest);
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.debug('SSO: Session validation failed', { status: response.status });
|
||||
throw new UnauthorizedException('Invalid or expired session');
|
||||
}
|
||||
|
||||
const sessionData = await response.json();
|
||||
|
||||
if (!sessionData?.user || !sessionData?.session) {
|
||||
this.logger.debug('SSO: Invalid session response', { sessionData });
|
||||
throw new UnauthorizedException('Invalid session data');
|
||||
}
|
||||
|
||||
const { user, session } = sessionData;
|
||||
|
||||
this.logger.debug('SSO: Session validated, generating JWT tokens', {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
// Generate JWT access token using Better Auth's JWT plugin
|
||||
let accessToken = '';
|
||||
try {
|
||||
const api = this.auth.api as any;
|
||||
|
||||
const jwtResult = await api.signJWT({
|
||||
body: {
|
||||
payload: {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role || 'user',
|
||||
sid: session.id || '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
accessToken = jwtResult?.token || '';
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Better Auth signJWT returned empty token');
|
||||
}
|
||||
} catch (jwtError) {
|
||||
this.logger.warn('SSO: JWT generation failed, using session token', {
|
||||
error: jwtError instanceof Error ? jwtError.message : 'Unknown error',
|
||||
});
|
||||
// Use session token as fallback
|
||||
accessToken = session.token || sessionToken;
|
||||
}
|
||||
|
||||
this.logger.info('SSO: Successfully exchanged session cookie for JWT tokens', {
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
},
|
||||
accessToken,
|
||||
refreshToken: session.token || sessionToken,
|
||||
expiresIn: 15 * 60, // 15 minutes in seconds
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
'SSO: Token exchange failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
throw new UnauthorizedException('Failed to exchange session for tokens');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue