@open-mercato/core 0.4.5-develop-03023b2707 → 0.4.5-develop-6bdcebbece

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -95,13 +95,23 @@ async function POST(req) {
95
95
  email: user.email,
96
96
  roles: userRoleNames
97
97
  });
98
- const res = NextResponse.json({ ok: true, token, redirect: "/backend" });
99
- res.cookies.set("auth_token", token, { httpOnly: true, path: "/", sameSite: "lax", secure: process.env.NODE_ENV === "production", maxAge: 60 * 60 * 8 });
98
+ const responseData = {
99
+ ok: true,
100
+ token,
101
+ redirect: "/backend"
102
+ };
100
103
  if (remember) {
101
104
  const days = Number(process.env.REMEMBER_ME_DAYS || "30");
102
105
  const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1e3);
103
106
  const sess = await auth.createSession(user, expiresAt);
104
- res.cookies.set("session_token", sess.token, { httpOnly: true, path: "/", sameSite: "lax", secure: process.env.NODE_ENV === "production", expires: expiresAt });
107
+ responseData.refreshToken = sess.token;
108
+ }
109
+ const res = NextResponse.json(responseData);
110
+ res.cookies.set("auth_token", token, { httpOnly: true, path: "/", sameSite: "lax", secure: process.env.NODE_ENV === "production", maxAge: 60 * 60 * 8 });
111
+ if (remember && responseData.refreshToken) {
112
+ const days = Number(process.env.REMEMBER_ME_DAYS || "30");
113
+ const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1e3);
114
+ res.cookies.set("session_token", responseData.refreshToken, { httpOnly: true, path: "/", sameSite: "lax", secure: process.env.NODE_ENV === "production", expires: expiresAt });
105
115
  }
106
116
  return res;
107
117
  }
@@ -112,7 +122,8 @@ const loginRequestSchema = userLoginSchema.extend({
112
122
  const loginSuccessSchema = z.object({
113
123
  ok: z.literal(true),
114
124
  token: z.string().describe("JWT token issued for subsequent API calls"),
115
- redirect: z.string().nullable().describe("Next location the client should navigate to")
125
+ redirect: z.string().nullable().describe("Next location the client should navigate to"),
126
+ refreshToken: z.string().optional().describe("Long-lived refresh token for obtaining new access tokens (only present when remember=true)")
116
127
  });
117
128
  const loginErrorSchema = z.object({
118
129
  ok: z.literal(false),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/auth/api/login.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { userLoginSchema } from '@open-mercato/core/modules/auth/data/validators'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { signJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { EventBus } from '@open-mercato/events/types'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { checkAuthRateLimit, resetAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\n\nconst loginRateLimitConfig = readEndpointRateLimitConfig('LOGIN', {\n points: 5, duration: 60, blockDuration: 60, keyPrefix: 'login',\n})\nconst loginIpRateLimitConfig = readEndpointRateLimitConfig('LOGIN_IP', {\n points: 20, duration: 60, blockDuration: 60, keyPrefix: 'login-ip',\n})\n\nexport const metadata = {}\n\n// validation comes from userLoginSchema\n\nexport async function POST(req: Request) {\n const { translate } = await resolveTranslations()\n const form = await req.formData()\n const email = String(form.get('email') ?? '')\n const password = String(form.get('password') ?? '')\n const remember = parseBooleanToken(form.get('remember')?.toString()) === true\n const tenantIdRaw = String(form.get('tenantId') ?? form.get('tenant') ?? '').trim()\n const requireRoleRaw = (String(form.get('requireRole') ?? form.get('role') ?? '')).trim()\n const requiredRoles = requireRoleRaw ? requireRoleRaw.split(',').map((s) => s.trim()).filter(Boolean) : []\n // Rate limit \u2014 two layers, both checked before validation and DB work\n const { error: rateLimitError, compoundKey: rateLimitCompoundKey } = await checkAuthRateLimit({\n req, ipConfig: loginIpRateLimitConfig, compoundConfig: loginRateLimitConfig, compoundIdentifier: email,\n })\n if (rateLimitError) return rateLimitError\n const parsed = userLoginSchema.pick({ email: true, password: true, tenantId: true }).safeParse({\n email,\n password,\n tenantId: tenantIdRaw || undefined,\n })\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid credentials') }, { status: 400 })\n }\n const container = await createRequestContainer()\n const auth = (container.resolve('authService') as AuthService)\n const tenantId = parsed.data.tenantId ?? null\n let user = null\n if (tenantId) {\n user = await auth.findUserByEmailAndTenant(parsed.data.email, tenantId)\n } else {\n const users = await auth.findUsersByEmail(parsed.data.email)\n if (users.length > 1) {\n return NextResponse.json({\n ok: false,\n error: translate('auth.login.errors.tenantRequired', 'Use the login link provided with your tenant activation to continue.'),\n }, { status: 400 })\n }\n user = users[0] ?? null\n }\n if (!user || !user.passwordHash) {\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid email or password') }, { status: 401 })\n }\n const ok = await auth.verifyPassword(user, parsed.data.password)\n if (!ok) {\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid email or password') }, { status: 401 })\n }\n // Optional role requirement\n if (requiredRoles.length) {\n const userRoleNames = await auth.getUserRoles(user, tenantId ?? (user.tenantId ? String(user.tenantId) : null))\n const authorized = requiredRoles.some(r => userRoleNames.includes(r))\n if (!authorized) {\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.permissionDenied', 'Not authorized for this area') }, { status: 403 })\n }\n }\n await auth.updateLastLoginAt(user)\n // Reset rate limit counter on successful login so legitimate users aren't penalized for prior typos\n if (rateLimitCompoundKey) {\n await resetAuthRateLimit(rateLimitCompoundKey, loginRateLimitConfig)\n }\n const resolvedTenantId = tenantId ?? (user.tenantId ? String(user.tenantId) : null)\n const userRoleNames = await auth.getUserRoles(user, resolvedTenantId)\n try {\n const eventBus = (container.resolve('eventBus') as EventBus)\n void eventBus.emitEvent('query_index.coverage.warmup', {\n tenantId: resolvedTenantId,\n }).catch(() => undefined)\n } catch {\n // optional warmup\n }\n const token = signJwt({\n sub: String(user.id),\n tenantId: resolvedTenantId,\n orgId: user.organizationId ? String(user.organizationId) : null,\n email: user.email,\n roles: userRoleNames\n })\n const res = NextResponse.json({ ok: true, token, redirect: '/backend' })\n res.cookies.set('auth_token', token, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 8 })\n if (remember) {\n const days = Number(process.env.REMEMBER_ME_DAYS || '30')\n const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000)\n const sess = await auth.createSession(user, expiresAt)\n res.cookies.set('session_token', sess.token, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', expires: expiresAt })\n }\n return res\n}\n\nconst loginRequestSchema = userLoginSchema.extend({\n password: z.string().min(6).describe('User password'),\n remember: z.enum(['on', '1', 'true']).optional().describe('Persist the session (submit `on`, `1`, or `true`).'),\n}).describe('Login form payload')\n\nconst loginSuccessSchema = z.object({\n ok: z.literal(true),\n token: z.string().describe('JWT token issued for subsequent API calls'),\n redirect: z.string().nullable().describe('Next location the client should navigate to'),\n})\n\nconst loginErrorSchema = z.object({\n ok: z.literal(false),\n error: z.string(),\n})\n\nconst loginMethodDoc: OpenApiMethodDoc = {\n summary: 'Authenticate user credentials',\n description: 'Validates the submitted credentials and issues a bearer token cookie for subsequent API calls.',\n tags: ['Authentication & Accounts'],\n requestBody: {\n contentType: 'application/x-www-form-urlencoded',\n schema: loginRequestSchema,\n description: 'Form-encoded payload captured from the login form.',\n },\n responses: [\n {\n status: 200,\n description: 'Authentication succeeded',\n schema: loginSuccessSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Validation failed', schema: loginErrorSchema },\n { status: 401, description: 'Invalid credentials', schema: loginErrorSchema },\n { status: 403, description: 'User lacks required role', schema: loginErrorSchema },\n { status: 429, description: 'Too many login attempts', schema: rateLimitErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Authenticate user credentials',\n description: 'Accepts login form submissions and manages cookie/session issuance.',\n methods: {\n POST: loginMethodDoc,\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { userLoginSchema } from '@open-mercato/core/modules/auth/data/validators'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { signJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { EventBus } from '@open-mercato/events/types'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { checkAuthRateLimit, resetAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\n\nconst loginRateLimitConfig = readEndpointRateLimitConfig('LOGIN', {\n points: 5, duration: 60, blockDuration: 60, keyPrefix: 'login',\n})\nconst loginIpRateLimitConfig = readEndpointRateLimitConfig('LOGIN_IP', {\n points: 20, duration: 60, blockDuration: 60, keyPrefix: 'login-ip',\n})\n\nexport const metadata = {}\n\n// validation comes from userLoginSchema\n\nexport async function POST(req: Request) {\n const { translate } = await resolveTranslations()\n const form = await req.formData()\n const email = String(form.get('email') ?? '')\n const password = String(form.get('password') ?? '')\n const remember = parseBooleanToken(form.get('remember')?.toString()) === true\n const tenantIdRaw = String(form.get('tenantId') ?? form.get('tenant') ?? '').trim()\n const requireRoleRaw = (String(form.get('requireRole') ?? form.get('role') ?? '')).trim()\n const requiredRoles = requireRoleRaw ? requireRoleRaw.split(',').map((s) => s.trim()).filter(Boolean) : []\n // Rate limit \u2014 two layers, both checked before validation and DB work\n const { error: rateLimitError, compoundKey: rateLimitCompoundKey } = await checkAuthRateLimit({\n req, ipConfig: loginIpRateLimitConfig, compoundConfig: loginRateLimitConfig, compoundIdentifier: email,\n })\n if (rateLimitError) return rateLimitError\n const parsed = userLoginSchema.pick({ email: true, password: true, tenantId: true }).safeParse({\n email,\n password,\n tenantId: tenantIdRaw || undefined,\n })\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid credentials') }, { status: 400 })\n }\n const container = await createRequestContainer()\n const auth = (container.resolve('authService') as AuthService)\n const tenantId = parsed.data.tenantId ?? null\n let user = null\n if (tenantId) {\n user = await auth.findUserByEmailAndTenant(parsed.data.email, tenantId)\n } else {\n const users = await auth.findUsersByEmail(parsed.data.email)\n if (users.length > 1) {\n return NextResponse.json({\n ok: false,\n error: translate('auth.login.errors.tenantRequired', 'Use the login link provided with your tenant activation to continue.'),\n }, { status: 400 })\n }\n user = users[0] ?? null\n }\n if (!user || !user.passwordHash) {\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid email or password') }, { status: 401 })\n }\n const ok = await auth.verifyPassword(user, parsed.data.password)\n if (!ok) {\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid email or password') }, { status: 401 })\n }\n // Optional role requirement\n if (requiredRoles.length) {\n const userRoleNames = await auth.getUserRoles(user, tenantId ?? (user.tenantId ? String(user.tenantId) : null))\n const authorized = requiredRoles.some(r => userRoleNames.includes(r))\n if (!authorized) {\n return NextResponse.json({ ok: false, error: translate('auth.login.errors.permissionDenied', 'Not authorized for this area') }, { status: 403 })\n }\n }\n await auth.updateLastLoginAt(user)\n // Reset rate limit counter on successful login so legitimate users aren't penalized for prior typos\n if (rateLimitCompoundKey) {\n await resetAuthRateLimit(rateLimitCompoundKey, loginRateLimitConfig)\n }\n const resolvedTenantId = tenantId ?? (user.tenantId ? String(user.tenantId) : null)\n const userRoleNames = await auth.getUserRoles(user, resolvedTenantId)\n try {\n const eventBus = (container.resolve('eventBus') as EventBus)\n void eventBus.emitEvent('query_index.coverage.warmup', {\n tenantId: resolvedTenantId,\n }).catch(() => undefined)\n } catch {\n // optional warmup\n }\n const token = signJwt({\n sub: String(user.id),\n tenantId: resolvedTenantId,\n orgId: user.organizationId ? String(user.organizationId) : null,\n email: user.email,\n roles: userRoleNames\n })\n const responseData: { ok: true; token: string; redirect: string; refreshToken?: string } = {\n ok: true,\n token,\n redirect: '/backend',\n }\n if (remember) {\n const days = Number(process.env.REMEMBER_ME_DAYS || '30')\n const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000)\n const sess = await auth.createSession(user, expiresAt)\n responseData.refreshToken = sess.token\n }\n const res = NextResponse.json(responseData)\n res.cookies.set('auth_token', token, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 8 })\n if (remember && responseData.refreshToken) {\n const days = Number(process.env.REMEMBER_ME_DAYS || '30')\n const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000)\n res.cookies.set('session_token', responseData.refreshToken, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', expires: expiresAt })\n }\n return res\n}\n\nconst loginRequestSchema = userLoginSchema.extend({\n password: z.string().min(6).describe('User password'),\n remember: z.enum(['on', '1', 'true']).optional().describe('Persist the session (submit `on`, `1`, or `true`).'),\n}).describe('Login form payload')\n\nconst loginSuccessSchema = z.object({\n ok: z.literal(true),\n token: z.string().describe('JWT token issued for subsequent API calls'),\n redirect: z.string().nullable().describe('Next location the client should navigate to'),\n refreshToken: z.string().optional().describe('Long-lived refresh token for obtaining new access tokens (only present when remember=true)'),\n})\n\nconst loginErrorSchema = z.object({\n ok: z.literal(false),\n error: z.string(),\n})\n\nconst loginMethodDoc: OpenApiMethodDoc = {\n summary: 'Authenticate user credentials',\n description: 'Validates the submitted credentials and issues a bearer token cookie for subsequent API calls.',\n tags: ['Authentication & Accounts'],\n requestBody: {\n contentType: 'application/x-www-form-urlencoded',\n schema: loginRequestSchema,\n description: 'Form-encoded payload captured from the login form.',\n },\n responses: [\n {\n status: 200,\n description: 'Authentication succeeded',\n schema: loginSuccessSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Validation failed', schema: loginErrorSchema },\n { status: 401, description: 'Invalid credentials', schema: loginErrorSchema },\n { status: 403, description: 'User lacks required role', schema: loginErrorSchema },\n { status: 429, description: 'Too many login attempts', schema: rateLimitErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Authenticate user credentials',\n description: 'Accepts login form submissions and manages cookie/session issuance.',\n methods: {\n POST: loginMethodDoc,\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["userRoleNames"]
7
7
  }
@@ -36,28 +36,95 @@ async function GET(req) {
36
36
  res.cookies.set("auth_token", jwt, { httpOnly: true, path: "/", sameSite: "lax", secure: process.env.NODE_ENV === "production", maxAge: 60 * 60 * 8 });
37
37
  return res;
38
38
  }
39
+ async function POST(req) {
40
+ let token = null;
41
+ try {
42
+ const body = await req.json();
43
+ const parsed = refreshRequestSchema.safeParse(body);
44
+ if (parsed.success) {
45
+ token = parsed.data.refreshToken;
46
+ }
47
+ } catch {
48
+ }
49
+ if (!token) {
50
+ return NextResponse.json({ ok: false, error: "Missing or invalid refresh token" }, { status: 400 });
51
+ }
52
+ const c = await createRequestContainer();
53
+ const auth = c.resolve("authService");
54
+ const ctx = await auth.refreshFromSessionToken(token);
55
+ if (!ctx) {
56
+ return NextResponse.json({ ok: false, error: "Invalid or expired refresh token" }, { status: 401 });
57
+ }
58
+ const { user, roles } = ctx;
59
+ const jwt = signJwt({
60
+ sub: String(user.id),
61
+ tenantId: String(user.tenantId),
62
+ orgId: String(user.organizationId),
63
+ email: user.email,
64
+ roles
65
+ });
66
+ const res = NextResponse.json({
67
+ ok: true,
68
+ accessToken: jwt,
69
+ expiresIn: 60 * 60 * 8
70
+ });
71
+ res.cookies.set("auth_token", jwt, {
72
+ httpOnly: true,
73
+ path: "/",
74
+ sameSite: "lax",
75
+ secure: process.env.NODE_ENV === "production",
76
+ maxAge: 60 * 60 * 8
77
+ });
78
+ return res;
79
+ }
39
80
  const metadata = {
40
- GET: { requireAuth: false }
81
+ GET: { requireAuth: false },
82
+ POST: { requireAuth: false }
41
83
  };
42
84
  const refreshQuerySchema = z.object({
43
85
  redirect: z.string().optional().describe("Absolute or relative URL to redirect after refresh")
44
86
  });
87
+ const refreshRequestSchema = z.object({
88
+ refreshToken: z.string().min(1).describe("The refresh token obtained from login")
89
+ });
90
+ const refreshSuccessSchema = z.object({
91
+ ok: z.literal(true),
92
+ accessToken: z.string().describe("New JWT access token"),
93
+ expiresIn: z.number().describe("Token expiration time in seconds")
94
+ });
95
+ const refreshErrorSchema = z.object({
96
+ ok: z.literal(false),
97
+ error: z.string()
98
+ });
45
99
  const openApi = {
46
100
  tag: "Authentication & Accounts",
47
101
  summary: "Refresh session token",
48
102
  methods: {
49
103
  GET: {
50
- summary: "Refresh auth cookie from session token",
104
+ summary: "Refresh auth cookie from session token (browser)",
51
105
  description: "Exchanges an existing `session_token` cookie for a fresh JWT auth cookie and redirects the browser.",
52
106
  query: refreshQuerySchema,
53
107
  responses: [
54
108
  { status: 302, description: "Redirect to target location when session is valid", mediaType: "text/html" }
55
109
  ]
110
+ },
111
+ POST: {
112
+ summary: "Refresh access token (API/mobile)",
113
+ description: "Exchanges a refresh token for a new JWT access token. Pass the refresh token obtained from login in the request body.",
114
+ requestBody: { schema: refreshRequestSchema, contentType: "application/json" },
115
+ responses: [
116
+ { status: 200, description: "New access token issued", schema: refreshSuccessSchema }
117
+ ],
118
+ errors: [
119
+ { status: 400, description: "Missing refresh token", schema: refreshErrorSchema },
120
+ { status: 401, description: "Invalid or expired token", schema: refreshErrorSchema }
121
+ ]
56
122
  }
57
123
  }
58
124
  };
59
125
  export {
60
126
  GET,
127
+ POST,
61
128
  metadata,
62
129
  openApi
63
130
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/auth/api/session/refresh.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { toAbsoluteUrl } from '@open-mercato/shared/lib/url'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { signJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport { z } from 'zod'\n\nfunction parseCookie(req: Request, name: string): string | null {\n const cookie = req.headers.get('cookie') || ''\n const m = cookie.match(new RegExp('(?:^|;\\\\s*)' + name + '=([^;]+)'))\n return m ? decodeURIComponent(m[1]) : null\n}\n\nfunction sanitizeRedirect(param: string | null, baseUrl: string): string {\n const value = param || '/'\n try {\n const base = new URL(baseUrl)\n const resolved = new URL(value, baseUrl)\n if (resolved.origin === base.origin && resolved.pathname.startsWith('/')) {\n return resolved.pathname + resolved.search + resolved.hash\n }\n } catch {}\n return '/'\n}\n\nexport async function GET(req: Request) {\n const url = new URL(req.url)\n const baseUrl = process.env.APP_URL || `${url.protocol}//${url.host}`\n const redirectTo = sanitizeRedirect(url.searchParams.get('redirect'), baseUrl)\n const token = parseCookie(req, 'session_token')\n if (!token) return NextResponse.redirect(toAbsoluteUrl(req, '/login?redirect=' + encodeURIComponent(redirectTo)))\n const c = await createRequestContainer()\n const auth = c.resolve<AuthService>('authService')\n const ctx = await auth.refreshFromSessionToken(token)\n if (!ctx) return NextResponse.redirect(toAbsoluteUrl(req, '/login?redirect=' + encodeURIComponent(redirectTo)))\n const { user, roles } = ctx\n const jwt = signJwt({ sub: String(user.id), tenantId: String(user.tenantId), orgId: String(user.organizationId), email: user.email, roles })\n const res = NextResponse.redirect(toAbsoluteUrl(req, redirectTo))\n res.cookies.set('auth_token', jwt, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 8 })\n return res\n}\n\nexport const metadata = {\n GET: { requireAuth: false },\n}\n\nconst refreshQuerySchema = z.object({\n redirect: z.string().optional().describe('Absolute or relative URL to redirect after refresh'),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Refresh session token',\n methods: {\n GET: {\n summary: 'Refresh auth cookie from session token',\n description: 'Exchanges an existing `session_token` cookie for a fresh JWT auth cookie and redirects the browser.',\n query: refreshQuerySchema,\n responses: [\n { status: 302, description: 'Redirect to target location when session is valid', mediaType: 'text/html' },\n ],\n },\n },\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAE7B,SAAS,qBAAqB;AAC9B,SAAS,8BAA8B;AAEvC,SAAS,eAAe;AACxB,SAAS,SAAS;AAElB,SAAS,YAAY,KAAc,MAA6B;AAC9D,QAAM,SAAS,IAAI,QAAQ,IAAI,QAAQ,KAAK;AAC5C,QAAM,IAAI,OAAO,MAAM,IAAI,OAAO,gBAAgB,OAAO,UAAU,CAAC;AACpE,SAAO,IAAI,mBAAmB,EAAE,CAAC,CAAC,IAAI;AACxC;AAEA,SAAS,iBAAiB,OAAsB,SAAyB;AACvE,QAAM,QAAQ,SAAS;AACvB,MAAI;AACF,UAAM,OAAO,IAAI,IAAI,OAAO;AAC5B,UAAM,WAAW,IAAI,IAAI,OAAO,OAAO;AACvC,QAAI,SAAS,WAAW,KAAK,UAAU,SAAS,SAAS,WAAW,GAAG,GAAG;AACxE,aAAO,SAAS,WAAW,SAAS,SAAS,SAAS;AAAA,IACxD;AAAA,EACF,QAAQ;AAAA,EAAC;AACT,SAAO;AACT;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,MAAM,IAAI,IAAI,IAAI,GAAG;AAC3B,QAAM,UAAU,QAAQ,IAAI,WAAW,GAAG,IAAI,QAAQ,KAAK,IAAI,IAAI;AACnE,QAAM,aAAa,iBAAiB,IAAI,aAAa,IAAI,UAAU,GAAG,OAAO;AAC7E,QAAM,QAAQ,YAAY,KAAK,eAAe;AAC9C,MAAI,CAAC,MAAO,QAAO,aAAa,SAAS,cAAc,KAAK,qBAAqB,mBAAmB,UAAU,CAAC,CAAC;AAChH,QAAM,IAAI,MAAM,uBAAuB;AACvC,QAAM,OAAO,EAAE,QAAqB,aAAa;AACjD,QAAM,MAAM,MAAM,KAAK,wBAAwB,KAAK;AACpD,MAAI,CAAC,IAAK,QAAO,aAAa,SAAS,cAAc,KAAK,qBAAqB,mBAAmB,UAAU,CAAC,CAAC;AAC9G,QAAM,EAAE,MAAM,MAAM,IAAI;AACxB,QAAM,MAAM,QAAQ,EAAE,KAAK,OAAO,KAAK,EAAE,GAAG,UAAU,OAAO,KAAK,QAAQ,GAAG,OAAO,OAAO,KAAK,cAAc,GAAG,OAAO,KAAK,OAAO,MAAM,CAAC;AAC3I,QAAM,MAAM,aAAa,SAAS,cAAc,KAAK,UAAU,CAAC;AAChE,MAAI,QAAQ,IAAI,cAAc,KAAK,EAAE,UAAU,MAAM,MAAM,KAAK,UAAU,OAAO,QAAQ,QAAQ,IAAI,aAAa,cAAc,QAAQ,KAAK,KAAK,EAAE,CAAC;AACrJ,SAAO;AACT;AAEO,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM;AAC5B;AAEA,MAAM,qBAAqB,EAAE,OAAO;AAAA,EAClC,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,oDAAoD;AAC/F,CAAC;AAEM,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aAAa;AAAA,MACb,OAAO;AAAA,MACP,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,qDAAqD,WAAW,YAAY;AAAA,MAC1G;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { toAbsoluteUrl } from '@open-mercato/shared/lib/url'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { signJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport { z } from 'zod'\n\nfunction parseCookie(req: Request, name: string): string | null {\n const cookie = req.headers.get('cookie') || ''\n const m = cookie.match(new RegExp('(?:^|;\\\\s*)' + name + '=([^;]+)'))\n return m ? decodeURIComponent(m[1]) : null\n}\n\nfunction sanitizeRedirect(param: string | null, baseUrl: string): string {\n const value = param || '/'\n try {\n const base = new URL(baseUrl)\n const resolved = new URL(value, baseUrl)\n if (resolved.origin === base.origin && resolved.pathname.startsWith('/')) {\n return resolved.pathname + resolved.search + resolved.hash\n }\n } catch {}\n return '/'\n}\n\nexport async function GET(req: Request) {\n const url = new URL(req.url)\n const baseUrl = process.env.APP_URL || `${url.protocol}//${url.host}`\n const redirectTo = sanitizeRedirect(url.searchParams.get('redirect'), baseUrl)\n const token = parseCookie(req, 'session_token')\n if (!token) return NextResponse.redirect(toAbsoluteUrl(req, '/login?redirect=' + encodeURIComponent(redirectTo)))\n const c = await createRequestContainer()\n const auth = c.resolve<AuthService>('authService')\n const ctx = await auth.refreshFromSessionToken(token)\n if (!ctx) return NextResponse.redirect(toAbsoluteUrl(req, '/login?redirect=' + encodeURIComponent(redirectTo)))\n const { user, roles } = ctx\n const jwt = signJwt({ sub: String(user.id), tenantId: String(user.tenantId), orgId: String(user.organizationId), email: user.email, roles })\n const res = NextResponse.redirect(toAbsoluteUrl(req, redirectTo))\n res.cookies.set('auth_token', jwt, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 8 })\n return res\n}\n\nexport async function POST(req: Request) {\n let token: string | null = null\n\n try {\n const body = await req.json()\n const parsed = refreshRequestSchema.safeParse(body)\n if (parsed.success) {\n token = parsed.data.refreshToken\n }\n } catch {\n // Invalid JSON\n }\n\n if (!token) {\n return NextResponse.json({ ok: false, error: 'Missing or invalid refresh token' }, { status: 400 })\n }\n\n const c = await createRequestContainer()\n const auth = c.resolve<AuthService>('authService')\n const ctx = await auth.refreshFromSessionToken(token)\n\n if (!ctx) {\n return NextResponse.json({ ok: false, error: 'Invalid or expired refresh token' }, { status: 401 })\n }\n\n const { user, roles } = ctx\n const jwt = signJwt({\n sub: String(user.id),\n tenantId: String(user.tenantId),\n orgId: String(user.organizationId),\n email: user.email,\n roles,\n })\n\n const res = NextResponse.json({\n ok: true,\n accessToken: jwt,\n expiresIn: 60 * 60 * 8,\n })\n\n res.cookies.set('auth_token', jwt, {\n httpOnly: true,\n path: '/',\n sameSite: 'lax',\n secure: process.env.NODE_ENV === 'production',\n maxAge: 60 * 60 * 8,\n })\n\n return res\n}\n\nexport const metadata = {\n GET: { requireAuth: false },\n POST: { requireAuth: false },\n}\n\nconst refreshQuerySchema = z.object({\n redirect: z.string().optional().describe('Absolute or relative URL to redirect after refresh'),\n})\n\nconst refreshRequestSchema = z.object({\n refreshToken: z.string().min(1).describe('The refresh token obtained from login'),\n})\n\nconst refreshSuccessSchema = z.object({\n ok: z.literal(true),\n accessToken: z.string().describe('New JWT access token'),\n expiresIn: z.number().describe('Token expiration time in seconds'),\n})\n\nconst refreshErrorSchema = z.object({\n ok: z.literal(false),\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Refresh session token',\n methods: {\n GET: {\n summary: 'Refresh auth cookie from session token (browser)',\n description: 'Exchanges an existing `session_token` cookie for a fresh JWT auth cookie and redirects the browser.',\n query: refreshQuerySchema,\n responses: [\n { status: 302, description: 'Redirect to target location when session is valid', mediaType: 'text/html' },\n ],\n },\n POST: {\n summary: 'Refresh access token (API/mobile)',\n description: 'Exchanges a refresh token for a new JWT access token. Pass the refresh token obtained from login in the request body.',\n requestBody: { schema: refreshRequestSchema, contentType: 'application/json' },\n responses: [\n { status: 200, description: 'New access token issued', schema: refreshSuccessSchema },\n ],\n errors: [\n { status: 400, description: 'Missing refresh token', schema: refreshErrorSchema },\n { status: 401, description: 'Invalid or expired token', schema: refreshErrorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.4.5-develop-03023b2707",
3
+ "version": "0.4.5-develop-6bdcebbece",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -207,7 +207,7 @@
207
207
  }
208
208
  },
209
209
  "dependencies": {
210
- "@open-mercato/shared": "0.4.5-develop-03023b2707",
210
+ "@open-mercato/shared": "0.4.5-develop-6bdcebbece",
211
211
  "@types/semver": "^7.5.8",
212
212
  "@xyflow/react": "^12.6.0",
213
213
  "ai": "^6.0.0",
@@ -98,13 +98,23 @@ export async function POST(req: Request) {
98
98
  email: user.email,
99
99
  roles: userRoleNames
100
100
  })
101
- const res = NextResponse.json({ ok: true, token, redirect: '/backend' })
102
- res.cookies.set('auth_token', token, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 8 })
101
+ const responseData: { ok: true; token: string; redirect: string; refreshToken?: string } = {
102
+ ok: true,
103
+ token,
104
+ redirect: '/backend',
105
+ }
103
106
  if (remember) {
104
107
  const days = Number(process.env.REMEMBER_ME_DAYS || '30')
105
108
  const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000)
106
109
  const sess = await auth.createSession(user, expiresAt)
107
- res.cookies.set('session_token', sess.token, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', expires: expiresAt })
110
+ responseData.refreshToken = sess.token
111
+ }
112
+ const res = NextResponse.json(responseData)
113
+ res.cookies.set('auth_token', token, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 8 })
114
+ if (remember && responseData.refreshToken) {
115
+ const days = Number(process.env.REMEMBER_ME_DAYS || '30')
116
+ const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000)
117
+ res.cookies.set('session_token', responseData.refreshToken, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', expires: expiresAt })
108
118
  }
109
119
  return res
110
120
  }
@@ -118,6 +128,7 @@ const loginSuccessSchema = z.object({
118
128
  ok: z.literal(true),
119
129
  token: z.string().describe('JWT token issued for subsequent API calls'),
120
130
  redirect: z.string().nullable().describe('Next location the client should navigate to'),
131
+ refreshToken: z.string().optional().describe('Long-lived refresh token for obtaining new access tokens (only present when remember=true)'),
121
132
  })
122
133
 
123
134
  const loginErrorSchema = z.object({
@@ -41,25 +41,104 @@ export async function GET(req: Request) {
41
41
  return res
42
42
  }
43
43
 
44
+ export async function POST(req: Request) {
45
+ let token: string | null = null
46
+
47
+ try {
48
+ const body = await req.json()
49
+ const parsed = refreshRequestSchema.safeParse(body)
50
+ if (parsed.success) {
51
+ token = parsed.data.refreshToken
52
+ }
53
+ } catch {
54
+ // Invalid JSON
55
+ }
56
+
57
+ if (!token) {
58
+ return NextResponse.json({ ok: false, error: 'Missing or invalid refresh token' }, { status: 400 })
59
+ }
60
+
61
+ const c = await createRequestContainer()
62
+ const auth = c.resolve<AuthService>('authService')
63
+ const ctx = await auth.refreshFromSessionToken(token)
64
+
65
+ if (!ctx) {
66
+ return NextResponse.json({ ok: false, error: 'Invalid or expired refresh token' }, { status: 401 })
67
+ }
68
+
69
+ const { user, roles } = ctx
70
+ const jwt = signJwt({
71
+ sub: String(user.id),
72
+ tenantId: String(user.tenantId),
73
+ orgId: String(user.organizationId),
74
+ email: user.email,
75
+ roles,
76
+ })
77
+
78
+ const res = NextResponse.json({
79
+ ok: true,
80
+ accessToken: jwt,
81
+ expiresIn: 60 * 60 * 8,
82
+ })
83
+
84
+ res.cookies.set('auth_token', jwt, {
85
+ httpOnly: true,
86
+ path: '/',
87
+ sameSite: 'lax',
88
+ secure: process.env.NODE_ENV === 'production',
89
+ maxAge: 60 * 60 * 8,
90
+ })
91
+
92
+ return res
93
+ }
94
+
44
95
  export const metadata = {
45
96
  GET: { requireAuth: false },
97
+ POST: { requireAuth: false },
46
98
  }
47
99
 
48
100
  const refreshQuerySchema = z.object({
49
101
  redirect: z.string().optional().describe('Absolute or relative URL to redirect after refresh'),
50
102
  })
51
103
 
104
+ const refreshRequestSchema = z.object({
105
+ refreshToken: z.string().min(1).describe('The refresh token obtained from login'),
106
+ })
107
+
108
+ const refreshSuccessSchema = z.object({
109
+ ok: z.literal(true),
110
+ accessToken: z.string().describe('New JWT access token'),
111
+ expiresIn: z.number().describe('Token expiration time in seconds'),
112
+ })
113
+
114
+ const refreshErrorSchema = z.object({
115
+ ok: z.literal(false),
116
+ error: z.string(),
117
+ })
118
+
52
119
  export const openApi: OpenApiRouteDoc = {
53
120
  tag: 'Authentication & Accounts',
54
121
  summary: 'Refresh session token',
55
122
  methods: {
56
123
  GET: {
57
- summary: 'Refresh auth cookie from session token',
124
+ summary: 'Refresh auth cookie from session token (browser)',
58
125
  description: 'Exchanges an existing `session_token` cookie for a fresh JWT auth cookie and redirects the browser.',
59
126
  query: refreshQuerySchema,
60
127
  responses: [
61
128
  { status: 302, description: 'Redirect to target location when session is valid', mediaType: 'text/html' },
62
129
  ],
63
130
  },
131
+ POST: {
132
+ summary: 'Refresh access token (API/mobile)',
133
+ description: 'Exchanges a refresh token for a new JWT access token. Pass the refresh token obtained from login in the request body.',
134
+ requestBody: { schema: refreshRequestSchema, contentType: 'application/json' },
135
+ responses: [
136
+ { status: 200, description: 'New access token issued', schema: refreshSuccessSchema },
137
+ ],
138
+ errors: [
139
+ { status: 400, description: 'Missing refresh token', schema: refreshErrorSchema },
140
+ { status: 401, description: 'Invalid or expired token', schema: refreshErrorSchema },
141
+ ],
142
+ },
64
143
  },
65
144
  }