* clear

* feat: Add everything

* fix: Commenht
This commit is contained in:
Prad Nukala
2025-10-03 14:45:52 -04:00
committed by GitHub
parent 43b4a11c06
commit 13e6c3e84d
1935 changed files with 655061 additions and 40058 deletions
@@ -0,0 +1,30 @@
import { GET } from '../.well-known/openid-configuration/route';
describe('OIDC Discovery Endpoint', () => {
it('returns a valid OpenID Connect configuration', async () => {
const response = await GET(new Request('https://example.com/.well-known/openid-configuration'));
expect(response.status).toBe(200);
const config = await response.json();
// Basic structure validation
expect(config).toHaveProperty('issuer');
expect(config).toHaveProperty('authorization_endpoint');
expect(config).toHaveProperty('token_endpoint');
expect(config).toHaveProperty('userinfo_endpoint');
expect(config).toHaveProperty('jwks_uri');
// Validate specific SIOP requirements
expect(config.subject_syntax_types_supported).toContain('did');
expect(config.id_token_types_supported).toContain('subject-signed_id_token');
});
it('responds with correct CORS headers', async () => {
const response = await GET(new Request('https://example.com/.well-known/openid-configuration'));
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET');
expect(response.headers.get('Content-Type')).toBe('application/json');
});
});
@@ -0,0 +1,71 @@
import { NextRequest } from 'next/server';
import { POST } from '../token/route';
describe('OIDC Token Endpoint', () => {
it('handles authorization code token exchange', async () => {
const mockRequest = new NextRequest(new URL('https://example.com/token'), {
method: 'POST',
body: JSON.stringify({
grant_type: 'authorization_code',
code: 'valid_code',
client_id: 'test_client',
redirect_uri: 'https://example.com/callback',
code_verifier: 'test_verifier',
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const tokens = await response.json();
expect(tokens).toHaveProperty('access_token');
expect(tokens).toHaveProperty('token_type', 'Bearer');
expect(tokens).toHaveProperty('expires_in');
});
it('handles refresh token grant', async () => {
const mockRequest = new NextRequest(new URL('https://example.com/token'), {
method: 'POST',
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: 'valid_refresh_token',
client_id: 'test_client',
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const tokens = await response.json();
expect(tokens).toHaveProperty('access_token');
expect(tokens).toHaveProperty('token_type', 'Bearer');
});
it('rejects invalid grant types', async () => {
const mockRequest = new NextRequest(new URL('https://example.com/token'), {
method: 'POST',
body: JSON.stringify({
grant_type: 'invalid_grant',
client_id: 'test_client',
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const response = await POST(mockRequest);
expect(response.status).toBe(400);
const error = await response.json();
expect(error).toHaveProperty('error', 'unsupported_grant_type');
});
});
@@ -0,0 +1,227 @@
import { type NextRequest, NextResponse } from 'next/server';
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
interface OIDCAuthorizationParams {
response_type: string;
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
nonce?: string;
code_challenge?: string;
code_challenge_method?: string;
}
/**
* GET /api/oidc/authorize
*
* OIDC Authorization endpoint that initiates the authorization code flow.
* This endpoint proxies the request to the Go bridge handler at /oidc/authorize.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
try {
const { searchParams } = new URL(request.url);
// Extract and validate required OIDC parameters
const authParams: OIDCAuthorizationParams = {
response_type: searchParams.get('response_type') || '',
client_id: searchParams.get('client_id') || '',
redirect_uri: searchParams.get('redirect_uri') || '',
scope: searchParams.get('scope') || '',
state: searchParams.get('state') || undefined,
nonce: searchParams.get('nonce') || undefined,
code_challenge: searchParams.get('code_challenge') || undefined,
code_challenge_method: searchParams.get('code_challenge_method') || undefined,
};
// Validate required parameters
if (
!authParams.response_type ||
!authParams.client_id ||
!authParams.redirect_uri ||
!authParams.scope
) {
return NextResponse.json(
{
error: 'invalid_request',
error_description:
'Missing required parameters: response_type, client_id, redirect_uri, or scope',
},
{ status: 400 }
);
}
// Build query string for the bridge handler
const queryParams = new URLSearchParams();
Object.entries(authParams).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
queryParams.append(key, value);
}
});
// Forward the request to the Go bridge handler
const response = await fetch(`${BRIDGE_URL}/oidc/authorize?${queryParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
// Forward authentication and session headers
Authorization: request.headers.get('Authorization') || '',
Cookie: request.headers.get('Cookie') || '',
'User-Agent': request.headers.get('User-Agent') || '',
Origin: request.headers.get('Origin') || '',
Referer: request.headers.get('Referer') || '',
},
});
// Handle different response types from the bridge
if (response.status === 302) {
// Bridge handler returned a redirect - follow it
const location = response.headers.get('Location');
if (location) {
return NextResponse.redirect(location);
}
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({
error: 'authorization_error',
error_description: 'Authorization request failed',
}));
console.error('OIDC Authorization error:', response.status, errorData);
return NextResponse.json(errorData, { status: response.status });
}
const responseData = await response.json();
// Return the authorization response with appropriate headers
return NextResponse.json(responseData, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
'Access-Control-Allow-Credentials': 'true',
},
});
} catch (error) {
console.error('OIDC Authorization proxy error:', error);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Internal server error during authorization',
},
{ status: 500 }
);
}
}
/**
* POST /api/oidc/authorize
*
* Handle authorization requests sent via POST (form-encoded).
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const formData = await request.formData();
// Extract parameters from form data
const authParams: OIDCAuthorizationParams = {
response_type: formData.get('response_type')?.toString() || '',
client_id: formData.get('client_id')?.toString() || '',
redirect_uri: formData.get('redirect_uri')?.toString() || '',
scope: formData.get('scope')?.toString() || '',
state: formData.get('state')?.toString() || undefined,
nonce: formData.get('nonce')?.toString() || undefined,
code_challenge: formData.get('code_challenge')?.toString() || undefined,
code_challenge_method: formData.get('code_challenge_method')?.toString() || undefined,
};
// Validate required parameters
if (
!authParams.response_type ||
!authParams.client_id ||
!authParams.redirect_uri ||
!authParams.scope
) {
return NextResponse.json(
{
error: 'invalid_request',
error_description:
'Missing required parameters: response_type, client_id, redirect_uri, or scope',
},
{ status: 400 }
);
}
// Forward as form data to the bridge handler
const bridgeFormData = new FormData();
Object.entries(authParams).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
bridgeFormData.append(key, value);
}
});
const response = await fetch(`${BRIDGE_URL}/oidc/authorize`, {
method: 'POST',
body: bridgeFormData,
headers: {
// Forward authentication and session headers
Authorization: request.headers.get('Authorization') || '',
Cookie: request.headers.get('Cookie') || '',
'User-Agent': request.headers.get('User-Agent') || '',
Origin: request.headers.get('Origin') || '',
Referer: request.headers.get('Referer') || '',
},
});
// Handle redirect response
if (response.status === 302) {
const location = response.headers.get('Location');
if (location) {
return NextResponse.redirect(location);
}
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({
error: 'authorization_error',
error_description: 'Authorization request failed',
}));
return NextResponse.json(errorData, { status: response.status });
}
const responseData = await response.json();
return NextResponse.json(responseData);
} catch (error) {
console.error('OIDC Authorization POST proxy error:', error);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Internal server error during authorization',
},
{ status: 500 }
);
}
}
/**
* OPTIONS /api/oidc/authorize
*
* Handle preflight CORS requests for the authorization endpoint.
*/
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Cookie',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
},
});
}
+158
View File
@@ -0,0 +1,158 @@
import { type NextRequest, NextResponse } from 'next/server';
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
interface JWK {
kty: string;
use?: string;
kid: string;
alg?: string;
n?: string; // RSA modulus
e?: string; // RSA exponent
x?: string; // EC x coordinate
y?: string; // EC y coordinate
crv?: string; // EC curve
}
interface JWKSet {
keys: JWK[];
}
/**
* GET /api/oidc/jwks
*
* OIDC JSON Web Key Set (JWKS) endpoint that returns public keys used for token verification.
* This endpoint proxies the request to the Go bridge handler at /oidc/jwks.
* The returned keys are used by relying parties to verify JWT tokens issued by this OIDC provider.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
try {
// Forward the request to the Go bridge handler
const response = await fetch(`${BRIDGE_URL}/oidc/jwks`, {
method: 'GET',
headers: {
Accept: 'application/json',
'User-Agent': request.headers.get('User-Agent') || '',
Origin: request.headers.get('Origin') || '',
},
});
if (!response.ok) {
console.error('OIDC JWKS error:', response.status, response.statusText);
let errorData;
try {
errorData = await response.json();
} catch {
errorData = {
error: 'server_error',
error_description: 'Failed to retrieve JSON Web Key Set',
};
}
return NextResponse.json(errorData, { status: response.status });
}
const jwks: JWKSet = await response.json();
// Validate JWKS structure
if (!jwks || !Array.isArray(jwks.keys)) {
console.error('Invalid JWKS response structure:', jwks);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Invalid JWKS response from server',
},
{ status: 500 }
);
}
// Validate each key in the set
const validKeys = jwks.keys.filter((key: JWK) => {
// Basic validation for required JWK fields
if (!key.kty || !key.kid) {
console.warn('Invalid JWK missing required fields:', key);
return false;
}
// Validate key type specific fields
if (key.kty === 'RSA' && (!key.n || !key.e)) {
console.warn('Invalid RSA JWK missing n or e:', key);
return false;
}
if (key.kty === 'EC' && (!key.x || !key.y || !key.crv)) {
console.warn('Invalid EC JWK missing x, y, or crv:', key);
return false;
}
return true;
});
const validatedJWKS: JWKSet = {
keys: validKeys,
};
// Return the JWKS with appropriate headers
return NextResponse.json(validatedJWKS, {
status: 200,
headers: {
'Content-Type': 'application/json',
// JWKS can be cached longer since keys don't change frequently
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=43200',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type',
// Security headers for key endpoint
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
},
});
} catch (error) {
console.error('OIDC JWKS proxy error:', error);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Internal server error during JWKS retrieval',
},
{ status: 500 }
);
}
}
/**
* OPTIONS /api/oidc/jwks
*
* Handle preflight CORS requests for the JWKS endpoint.
*/
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
},
});
}
/**
* POST /api/oidc/jwks
*
* Return method not allowed for POST requests to JWKS endpoint.
*/
export async function POST(): Promise<NextResponse> {
return NextResponse.json(
{
error: 'method_not_allowed',
error_description: 'POST method not allowed for JWKS endpoint. Use GET.',
},
{
status: 405,
headers: {
Allow: 'GET, OPTIONS',
},
}
);
}
+196
View File
@@ -0,0 +1,196 @@
import { type NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-static';
export const revalidate = 3600; // Revalidate every hour
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
interface OIDCTokenRequest {
grant_type: string;
code?: string;
redirect_uri?: string;
client_id: string;
client_secret?: string;
code_verifier?: string;
refresh_token?: string;
}
interface OIDCTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
id_token?: string;
scope?: string;
}
/**
* POST /api/oidc/token
*
* OIDC Token endpoint that exchanges authorization codes for access tokens.
* This endpoint proxies the request to the Go bridge handler at /oidc/token.
* Supports authorization_code, refresh_token, and client_credentials grant types.
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const contentType = request.headers.get('Content-Type') || '';
let tokenRequest: OIDCTokenRequest;
// Parse request based on content type
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
tokenRequest = {
grant_type: formData.get('grant_type')?.toString() || '',
code: formData.get('code')?.toString() || undefined,
redirect_uri: formData.get('redirect_uri')?.toString() || undefined,
client_id: formData.get('client_id')?.toString() || '',
client_secret: formData.get('client_secret')?.toString() || undefined,
code_verifier: formData.get('code_verifier')?.toString() || undefined,
refresh_token: formData.get('refresh_token')?.toString() || undefined,
};
} else if (contentType.includes('application/json')) {
tokenRequest = await request.json();
} else {
return NextResponse.json(
{
error: 'invalid_request',
error_description:
'Content-Type must be application/x-www-form-urlencoded or application/json',
},
{ status: 400 }
);
}
// Validate required parameters
if (!tokenRequest.grant_type || !tokenRequest.client_id) {
return NextResponse.json(
{
error: 'invalid_request',
error_description: 'Missing required parameters: grant_type and client_id',
},
{ status: 400 }
);
}
// Validate grant type specific parameters
if (tokenRequest.grant_type === 'authorization_code') {
if (!tokenRequest.code || !tokenRequest.redirect_uri) {
return NextResponse.json(
{
error: 'invalid_request',
error_description:
'Missing required parameters for authorization_code grant: code and redirect_uri',
},
{ status: 400 }
);
}
} else if (tokenRequest.grant_type === 'refresh_token') {
if (!tokenRequest.refresh_token) {
return NextResponse.json(
{
error: 'invalid_request',
error_description: 'Missing required parameter for refresh_token grant: refresh_token',
},
{ status: 400 }
);
}
}
// Prepare form data for the bridge handler (OIDC typically uses form encoding)
const bridgeFormData = new FormData();
Object.entries(tokenRequest).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
bridgeFormData.append(key, value);
}
});
// Forward the request to the Go bridge handler
const response = await fetch(`${BRIDGE_URL}/oidc/token`, {
method: 'POST',
body: bridgeFormData,
headers: {
Accept: 'application/json',
// Forward client authentication headers
Authorization: request.headers.get('Authorization') || '',
'User-Agent': request.headers.get('User-Agent') || '',
Origin: request.headers.get('Origin') || '',
},
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = {
error: 'server_error',
error_description: 'Token request failed',
};
}
console.error('OIDC Token error:', response.status, errorData);
return NextResponse.json(errorData, { status: response.status });
}
const tokenResponse: OIDCTokenResponse = await response.json();
// Return the token response with appropriate headers
return NextResponse.json(tokenResponse, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
'Access-Control-Allow-Credentials': 'true',
},
});
} catch (error) {
console.error('OIDC Token proxy error:', error);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Internal server error during token exchange',
},
{ status: 500 }
);
}
}
/**
* OPTIONS /api/oidc/token
*
* Handle preflight CORS requests for the token endpoint.
*/
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
},
});
}
/**
* GET /api/oidc/token
*
* Return method not allowed for GET requests to token endpoint.
*/
export async function GET(): Promise<NextResponse> {
return NextResponse.json(
{
error: 'invalid_request',
error_description: 'GET method not allowed for token endpoint. Use POST.',
},
{
status: 405,
headers: {
Allow: 'POST, OPTIONS',
},
}
);
}
+227
View File
@@ -0,0 +1,227 @@
import { type NextRequest, NextResponse } from 'next/server';
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
interface OIDCUserInfo {
sub: string;
name?: string;
preferred_username?: string;
email?: string;
email_verified?: boolean;
did?: string;
vault_id?: string;
updated_at?: number;
claims?: Record<string, unknown>;
}
/**
* GET /api/oidc/userinfo
*
* OIDC UserInfo endpoint that returns user information for a valid access token.
* This endpoint proxies the request to the Go bridge handler at /oidc/userinfo.
* Requires a valid Bearer token in the Authorization header.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
try {
// Extract access token from Authorization header
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{
error: 'invalid_token',
error_description:
'Missing or invalid Authorization header. Expected format: Bearer <access_token>',
},
{
status: 401,
headers: {
'WWW-Authenticate': 'Bearer realm="OIDC UserInfo", error="invalid_token"',
},
}
);
}
// Forward the request to the Go bridge handler
const response = await fetch(`${BRIDGE_URL}/oidc/userinfo`, {
method: 'GET',
headers: {
Accept: 'application/json',
// Forward the Authorization header with the access token
Authorization: authHeader,
'User-Agent': request.headers.get('User-Agent') || '',
Origin: request.headers.get('Origin') || '',
},
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
// Handle different error statuses
switch (response.status) {
case 401:
errorData = {
error: 'invalid_token',
error_description: 'Access token is invalid or expired',
};
break;
case 403:
errorData = {
error: 'insufficient_scope',
error_description: 'Access token does not have sufficient scope',
};
break;
default:
errorData = {
error: 'server_error',
error_description: 'UserInfo request failed',
};
}
}
console.error('OIDC UserInfo error:', response.status, errorData);
const responseHeaders: HeadersInit = {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
};
// Add WWW-Authenticate header for 401 responses
if (response.status === 401) {
responseHeaders['WWW-Authenticate'] = 'Bearer realm="OIDC UserInfo", error="invalid_token"';
}
return NextResponse.json(errorData, {
status: response.status,
headers: responseHeaders,
});
}
const userInfo: OIDCUserInfo = await response.json();
// Return the user information with appropriate headers
return NextResponse.json(userInfo, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
'Access-Control-Allow-Credentials': 'true',
},
});
} catch (error) {
console.error('OIDC UserInfo proxy error:', error);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Internal server error during userinfo retrieval',
},
{ status: 500 }
);
}
}
/**
* POST /api/oidc/userinfo
*
* OIDC UserInfo endpoint that accepts POST requests with access token in form data.
* This is an alternative method for clients that prefer POST over GET.
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const contentType = request.headers.get('Content-Type') || '';
let accessToken: string | null = null;
// Extract access token from different sources
const authHeader = request.headers.get('Authorization');
if (authHeader?.startsWith('Bearer ')) {
accessToken = authHeader.substring(7);
} else if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
accessToken = formData.get('access_token')?.toString() || null;
} else if (contentType.includes('application/json')) {
const body = await request.json();
accessToken = body.access_token || null;
}
if (!accessToken) {
return NextResponse.json(
{
error: 'invalid_token',
error_description: 'Access token required in Authorization header or request body',
},
{
status: 401,
headers: {
'WWW-Authenticate': 'Bearer realm="OIDC UserInfo", error="invalid_token"',
},
}
);
}
// Forward the request to the Go bridge handler
const response = await fetch(`${BRIDGE_URL}/oidc/userinfo`, {
method: 'GET', // Bridge handler expects GET
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
'User-Agent': request.headers.get('User-Agent') || '',
Origin: request.headers.get('Origin') || '',
},
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = {
error: 'server_error',
error_description: 'UserInfo request failed',
};
}
return NextResponse.json(errorData, { status: response.status });
}
const userInfo: OIDCUserInfo = await response.json();
return NextResponse.json(userInfo, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
},
});
} catch (error) {
console.error('OIDC UserInfo POST proxy error:', error);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Internal server error during userinfo retrieval',
},
{ status: 500 }
);
}
}
/**
* OPTIONS /api/oidc/userinfo
*
* Handle preflight CORS requests for the userinfo endpoint.
*/
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
},
});
}