* 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,76 @@
import { type NextRequest, NextResponse } from 'next/server';
const BRIDGE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
/**
* GET /.well-known/openid-configuration
*
* OpenID Connect Discovery endpoint that returns the OIDC provider configuration.
* This endpoint proxies the request to the Go bridge handler at /oidc/discovery.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
try {
// Forward the request to the Go bridge handler
const response = await fetch(`${BRIDGE_URL}/oidc/discovery`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
// Forward relevant headers
'User-Agent': request.headers.get('User-Agent') || '',
Origin: request.headers.get('Origin') || '',
},
});
if (!response.ok) {
console.error('OIDC Discovery error:', response.status, response.statusText);
return NextResponse.json(
{
error: 'discovery_error',
error_description: 'Failed to retrieve OIDC discovery configuration',
},
{ status: response.status }
);
}
const discoveryConfig = await response.json();
// Return the discovery configuration with CORS headers
return NextResponse.json(discoveryConfig, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
} catch (error) {
console.error('OIDC Discovery proxy error:', error);
return NextResponse.json(
{
error: 'server_error',
error_description: 'Internal server error during discovery configuration retrieval',
},
{ status: 500 }
);
}
}
/**
* OPTIONS /.well-known/openid-configuration
*
* Handle preflight CORS requests for the discovery 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, Authorization',
'Access-Control-Max-Age': '86400',
},
});
}
@@ -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',
},
});
}
+299
View File
@@ -0,0 +1,299 @@
'use client';
import {
ArrowLeftIcon,
ArrowTopRightOnSquareIcon,
ShieldCheckIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { useEffect, useState } from 'react';
interface OAuthGrant {
id: string;
clientId: string;
clientName: string;
clientLogo?: string;
scopes: string[];
capabilities: UCANCapability[];
grantedAt: string;
lastUsed: string;
expiresAt?: string;
status: 'active' | 'expired' | 'revoked';
}
interface UCANCapability {
action: string;
resource: string;
caveats?: Record<string, any>;
}
export default function CapabilitiesPage() {
const [grants, setGrants] = useState<OAuthGrant[]>([]);
const [loading, setLoading] = useState(true);
const [selectedGrant, setSelectedGrant] = useState<OAuthGrant | null>(null);
const [revoking, setRevoking] = useState<string | null>(null);
useEffect(() => {
fetchGrants();
}, []);
const fetchGrants = async () => {
try {
// TODO: Replace with actual API call
const mockGrants: OAuthGrant[] = [
{
id: 'grant_1',
clientId: 'example-app',
clientName: 'Example Application',
scopes: ['vault:read', 'vault:write', 'profile'],
capabilities: [
{ action: 'read', resource: 'vault:*' },
{ action: 'write', resource: 'vault:*' },
{ action: 'read', resource: 'profile:*' },
],
grantedAt: '2024-01-15T10:00:00Z',
lastUsed: '2024-01-20T15:30:00Z',
status: 'active',
},
{
id: 'grant_2',
clientId: 'defi-wallet',
clientName: 'DeFi Wallet',
scopes: ['dwn:read', 'svc:register'],
capabilities: [
{ action: 'read', resource: 'dwn:*' },
{ action: 'register', resource: 'svc:*' },
],
grantedAt: '2024-01-10T08:00:00Z',
lastUsed: '2024-01-18T12:00:00Z',
expiresAt: '2024-02-10T08:00:00Z',
status: 'active',
},
];
setGrants(mockGrants);
} catch (error) {
console.error('Failed to fetch grants:', error);
} finally {
setLoading(false);
}
};
const revokeGrant = async (grantId: string) => {
setRevoking(grantId);
try {
// TODO: Implement actual revocation API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setGrants((prev) =>
prev.map((grant) =>
grant.id === grantId ? { ...grant, status: 'revoked' as const } : grant
)
);
if (selectedGrant?.id === grantId) {
setSelectedGrant(null);
}
} catch (error) {
console.error('Failed to revoke grant:', error);
} finally {
setRevoking(null);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusColor = (status: OAuthGrant['status']) => {
switch (status) {
case 'active':
return 'bg-green-100 text-green-800';
case 'expired':
return 'bg-yellow-100 text-yellow-800';
case 'revoked':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<Link
href="/"
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 mb-4"
>
<ArrowLeftIcon className="w-4 h-4 mr-1" />
Back to Home
</Link>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">OAuth Capabilities</h1>
<p className="mt-2 text-gray-600">
Manage applications and services that have access to your account
</p>
</div>
<ShieldCheckIcon className="w-10 h-10 text-blue-600" />
</div>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
) : grants.length === 0 ? (
<div className="bg-white rounded-lg shadow p-8 text-center">
<ShieldCheckIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No Active Grants</h3>
<p className="text-gray-600">
You haven't granted any applications access to your account yet.
</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Grants List */}
<div className="lg:col-span-2 space-y-4">
{grants.map((grant) => (
<div
key={grant.id}
className={`bg-white rounded-lg shadow p-6 cursor-pointer transition-all hover:shadow-lg ${
selectedGrant?.id === grant.id ? 'ring-2 ring-blue-500' : ''
}`}
onClick={() => setSelectedGrant(grant)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center">
{grant.clientLogo ? (
<img
src={grant.clientLogo}
alt={grant.clientName}
className="w-10 h-10 rounded-lg mr-3"
/>
) : (
<div className="w-10 h-10 bg-gray-200 rounded-lg mr-3 flex items-center justify-center">
<span className="text-gray-600 font-semibold">
{grant.clientName[0]}
</span>
</div>
)}
<div>
<h3 className="text-lg font-semibold text-gray-900">
{grant.clientName}
</h3>
<p className="text-sm text-gray-500">Client ID: {grant.clientId}</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{grant.scopes.map((scope) => (
<span
key={scope}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
>
{scope}
</span>
))}
</div>
<div className="mt-4 text-sm text-gray-600">
<p>Granted: {formatDate(grant.grantedAt)}</p>
<p>Last used: {formatDate(grant.lastUsed)}</p>
{grant.expiresAt && <p>Expires: {formatDate(grant.expiresAt)}</p>}
</div>
</div>
<div className="flex flex-col items-end ml-4">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(grant.status)}`}
>
{grant.status}
</span>
{grant.status === 'active' && (
<button
onClick={(e) => {
e.stopPropagation();
revokeGrant(grant.id);
}}
disabled={revoking === grant.id}
className="mt-4 inline-flex items-center px-3 py-1 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
>
{revoking === grant.id ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-700" />
) : (
<>
<TrashIcon className="w-4 h-4 mr-1" />
Revoke
</>
)}
</button>
)}
</div>
</div>
</div>
))}
</div>
{/* Grant Details */}
{selectedGrant && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Capability Details</h3>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">UCAN Capabilities</h4>
<div className="space-y-2">
{selectedGrant.capabilities.map((cap, index) => (
<div key={index} className="p-3 bg-gray-50 rounded-lg text-sm">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">{cap.action}</span>
<span className="text-gray-600">{cap.resource}</span>
</div>
{cap.caveats && Object.keys(cap.caveats).length > 0 && (
<div className="mt-2 text-xs text-gray-500">
Caveats: {JSON.stringify(cap.caveats)}
</div>
)}
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Delegation Chain</h4>
<button className="inline-flex items-center text-sm text-blue-600 hover:text-blue-500">
View full delegation chain
<ArrowTopRightOnSquareIcon className="w-3 h-3 ml-1" />
</button>
</div>
<div className="pt-4 border-t">
<h4 className="text-sm font-medium text-gray-700 mb-2">Audit Log</h4>
<p className="text-sm text-gray-600">View all activities for this grant</p>
<button className="mt-2 inline-flex items-center text-sm text-blue-600 hover:text-blue-500">
View audit log
<ArrowTopRightOnSquareIcon className="w-3 h-3 ml-1" />
</button>
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
'use client';
import { useSession } from '@/hooks/useSession';
import { Button, ErrorAlert } from '@sonr.io/ui';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function DashboardPage() {
const { user, isAuthenticated, isLoading, error, logout } = useSession();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, isLoading, router]);
const handleLogout = async () => {
await logout();
router.push('/');
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated || !user) {
return null; // Will redirect to login
}
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div className="flex items-center">
<h1 className="text-2xl font-bold text-gray-900">Highway Dashboard</h1>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-700">
Welcome, {user.displayName || user.username}
</span>
<Button variant="secondary" size="sm" onClick={handleLogout}>
Sign Out
</Button>
</div>
</div>
</div>
</div>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
{error && (
<div className="mb-6">
<ErrorAlert message={error} />
</div>
)}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<h2 className="text-xl font-semibold text-gray-900">Account Information</h2>
<p className="mt-2 text-sm text-gray-700">
Your account details and authentication status
</p>
</div>
</div>
<div className="mt-6 border-t border-gray-200 pt-6">
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-gray-500">Username</dt>
<dd className="mt-1 text-sm text-gray-900">{user.username}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Display Name</dt>
<dd className="mt-1 text-sm text-gray-900">{user.displayName || 'Not set'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Account Created</dt>
<dd className="mt-1 text-sm text-gray-900">
{new Date(user.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Authentication Method</dt>
<dd className="mt-1 text-sm text-gray-900">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
WebAuthn Passkey
</span>
</dd>
</div>
</dl>
</div>
</div>
</div>
<div className="mt-6 bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Security Features
</h3>
<ul className="space-y-3">
<li className="flex items-center">
<svg
className="h-5 w-5 text-green-500 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
role="img"
aria-label="Check mark"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-700">Passwordless Authentication</span>
</li>
<li className="flex items-center">
<svg
className="h-5 w-5 text-green-500 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
role="img"
aria-label="Check mark"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-700">Biometric Verification</span>
</li>
<li className="flex items-center">
<svg
className="h-5 w-5 text-green-500 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
role="img"
aria-label="Check mark"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-700">Phishing Resistant</span>
</li>
<li className="flex items-center">
<svg
className="h-5 w-5 text-green-500 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
role="img"
aria-label="Check mark"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-700">Hardware Security Key Support</span>
</li>
</ul>
</div>
</div>
</div>
</main>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
/* Additional app-specific styles */
:root {
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
body {
font-feature-settings: "rlig" 1, "calt" 1;
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Highway - Sonr Authentication Gateway',
description:
'Passwordless authentication using WebAuthn passkeys for the Sonr blockchain ecosystem',
keywords: ['webauthn', 'passkey', 'authentication', 'sonr', 'blockchain'],
authors: [{ name: 'Sonr' }],
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}
+150
View File
@@ -0,0 +1,150 @@
'use client';
import { useWebAuthn } from '@/hooks/useWebAuthn';
import { Button, ErrorAlert, Input } from '@sonr.io/ui';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [isSupported, setIsSupported] = useState<boolean | null>(null);
const { authenticateUser, isLoading, error, clearError } = useWebAuthn();
const router = useRouter();
// Check WebAuthn support on component mount
useState(() => {
const checkSupport = async () => {
if (typeof window !== 'undefined') {
const supported =
window.PublicKeyCredential &&
typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable ===
'function';
setIsSupported(supported);
}
};
checkSupport();
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
if (!username.trim()) {
return;
}
try {
const success = await authenticateUser(username.trim());
if (success) {
router.push('/dashboard');
}
} catch (_err) {
// Error is handled by the hook
}
};
if (isSupported === false) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
WebAuthn Not Supported
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Your browser doesn't support WebAuthn. Please use a modern browser like Chrome,
Firefox, Safari, or Edge.
</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-green-100">
<svg
className="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Authentication Successful Icon"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Use your passkey to securely access your account
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && <ErrorAlert message={error} onDismiss={clearError} />}
<div>
<Input
label="Username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
autoComplete="username"
helpText="Enter the username you registered with"
/>
</div>
<div>
<Button
type="submit"
size="lg"
isLoading={isLoading}
disabled={!username.trim() || isLoading}
className="w-full"
>
{isLoading ? 'Signing In...' : 'Sign In with Passkey'}
</Button>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">
Don't have an account?{' '}
<a href="/register" className="font-medium text-blue-600 hover:text-blue-500">
Create one here
</a>
</p>
</div>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gray-50 text-gray-500">Secure & Simple</span>
</div>
</div>
<div className="mt-4 text-sm text-gray-600 space-y-2">
<p> No passwords to type or remember</p>
<p> Authenticate with your device's biometrics</p>
<p> Protected against phishing and breaches</p>
</div>
</div>
</div>
</div>
);
}
+325
View File
@@ -0,0 +1,325 @@
'use client';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@sonr.io/ui';
import { Button } from '@sonr.io/ui';
import { Alert, AlertDescription } from '@sonr.io/ui';
import { OAuth2Client, parseCallbackUrl } from '@sonr.io/ui';
import { AlertCircle, Check, Shield } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { Suspense } from 'react';
interface AuthorizePageProps {
searchParams: { [key: string]: string | string[] | undefined };
}
function AuthorizeContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = React.useState(false);
// Extract OAuth parameters
const clientId = searchParams.get('client_id');
const redirectUri = searchParams.get('redirect_uri');
const responseType = searchParams.get('response_type');
const scope = searchParams.get('scope');
const state = searchParams.get('state');
const codeChallenge = searchParams.get('code_challenge');
const codeChallengeMethod = searchParams.get('code_challenge_method');
// Get client information (in production, fetch from server)
const clientInfo = React.useMemo(() => {
// Mock client data - replace with actual client registry lookup
const clients: Record<string, { name: string; logo?: string; trusted: boolean }> = {
dev_client_123: { name: 'Development Client', trusted: true },
example_app: { name: 'Example Application', trusted: false },
};
return clients[clientId || ''] || { name: 'Unknown Application', trusted: false };
}, [clientId]);
// Check if user is authenticated
React.useEffect(() => {
const checkAuth = async () => {
try {
// Check for existing session
const token = localStorage.getItem('sonr_auth_token');
if (token) {
setIsAuthenticated(true);
}
} catch (err) {
console.error('Auth check failed:', err);
}
};
checkAuth();
}, []);
// Validate request parameters
React.useEffect(() => {
if (!clientId || !redirectUri || !responseType) {
setError('Missing required OAuth parameters');
return;
}
if (responseType !== 'code' && responseType !== 'token') {
setError('Invalid response type. Only "code" and "token" are supported.');
return;
}
// Validate PKCE for public clients
if (!codeChallenge && responseType === 'code') {
setError('PKCE code challenge is required for public clients');
return;
}
if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
setError('Only S256 code challenge method is supported');
return;
}
}, [clientId, redirectUri, responseType, codeChallenge, codeChallengeMethod]);
// Handle authorization approval
const handleApprove = React.useCallback(async () => {
setIsLoading(true);
setError(null);
try {
if (!isAuthenticated) {
// Redirect to login with return URL
const returnUrl = `/oauth/authorize?${searchParams.toString()}`;
router.push(`/login?return_url=${encodeURIComponent(returnUrl)}`);
return;
}
// Generate authorization code
const response = await fetch('/api/oauth/authorize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('sonr_auth_token')}`,
},
body: JSON.stringify({
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
approved: true,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error_description || 'Authorization failed');
}
const result = await response.json();
// Redirect to client with authorization code
const redirectUrl = new URL(redirectUri!);
if (responseType === 'code') {
redirectUrl.searchParams.set('code', result.code);
} else {
// Implicit flow - add token to fragment
const fragment = new URLSearchParams({
access_token: result.access_token,
token_type: 'Bearer',
expires_in: result.expires_in.toString(),
scope: scope || '',
});
if (state) fragment.set('state', state);
redirectUrl.hash = fragment.toString();
}
if (state) {
redirectUrl.searchParams.set('state', state);
}
window.location.href = redirectUrl.toString();
} catch (err) {
setError(err instanceof Error ? err.message : 'Authorization failed');
} finally {
setIsLoading(false);
}
}, [
isAuthenticated,
clientId,
redirectUri,
responseType,
scope,
state,
codeChallenge,
codeChallengeMethod,
searchParams,
router,
]);
// Handle denial
const handleDeny = React.useCallback(() => {
const redirectUrl = new URL(redirectUri!);
redirectUrl.searchParams.set('error', 'access_denied');
redirectUrl.searchParams.set('error_description', 'User denied authorization');
if (state) {
redirectUrl.searchParams.set('state', state);
}
window.location.href = redirectUrl.toString();
}, [redirectUri, state]);
// Parse requested scopes
const requestedScopes = React.useMemo(() => {
if (!scope) return [];
const scopeDescriptions: Record<
string,
{ title: string; description: string; icon: React.ReactNode }
> = {
openid: {
title: 'Basic Profile',
description: 'Your Sonr ID and basic profile information',
icon: <Shield className="h-4 w-4" />,
},
profile: {
title: 'Profile Information',
description: 'Your name, picture, and other profile details',
icon: <Shield className="h-4 w-4" />,
},
'vault:read': {
title: 'Read Vault Data',
description: 'Read access to your encrypted vault',
icon: <Shield className="h-4 w-4" />,
},
'vault:write': {
title: 'Write Vault Data',
description: 'Create and modify data in your vault',
icon: <Shield className="h-4 w-4" />,
},
'vault:sign': {
title: 'Sign with Vault Keys',
description: 'Sign transactions and messages with your vault keys',
icon: <Shield className="h-4 w-4" />,
},
'service:manage': {
title: 'Manage Services',
description: 'Register and manage services on your behalf',
icon: <Shield className="h-4 w-4" />,
},
};
return scope.split(' ').map((s) => ({
scope: s,
...(scopeDescriptions[s] || {
title: s,
description: `Access to ${s}`,
icon: <Shield className="h-4 w-4" />,
}),
}));
}, [scope]);
if (error) {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
Authorization Error
</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
<CardFooter>
<Button variant="outline" onClick={() => window.history.back()}>
Go Back
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
<Card className="w-full max-w-md">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{clientInfo.logo && (
<img src={clientInfo.logo} alt={clientInfo.name} className="h-10 w-10 rounded" />
)}
<div>
<CardTitle>{clientInfo.name}</CardTitle>
<CardDescription>wants to access your Sonr account</CardDescription>
</div>
</div>
{clientInfo.trusted && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
Verified
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{!isAuthenticated && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
You need to sign in to continue with authorization
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<p className="text-sm font-medium">This application will be able to:</p>
<div className="space-y-2">
{requestedScopes.map(({ scope, title, description, icon }) => (
<div key={scope} className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<div className="mt-0.5">{icon}</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
))}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">
By authorizing, you allow this application to access your information in accordance
with its terms of service and privacy policy.
</p>
</div>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" onClick={handleDeny} disabled={isLoading} className="flex-1">
Deny
</Button>
<Button onClick={handleApprove} disabled={isLoading} className="flex-1">
{isLoading ? 'Authorizing...' : isAuthenticated ? 'Authorize' : 'Sign In & Authorize'}
</Button>
</CardFooter>
</Card>
</div>
);
}
export default function AuthorizePage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<AuthorizeContent />
</Suspense>
);
}
@@ -0,0 +1,451 @@
'use client';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@sonr.io/ui';
import { Button } from '@sonr.io/ui';
import { Alert, AlertDescription } from '@sonr.io/ui';
import { OAuth2Client, parseCallbackUrl } from '@sonr.io/ui';
import { AlertCircle, Check, Shield } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import React from 'react';
interface ClientBranding {
id: string;
name: string;
description?: string;
logo?: string;
verified: boolean;
theme?: {
primaryColor?: string;
accentColor?: string;
backgroundColor?: string;
cardBackground?: string;
borderRadius?: string;
fontFamily?: string;
};
customCSS?: string;
}
export default function ThemedAuthorizePage() {
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = React.useState(false);
// Extract OAuth parameters
const clientId = searchParams.get('client_id');
const redirectUri = searchParams.get('redirect_uri');
const responseType = searchParams.get('response_type');
const scope = searchParams.get('scope');
const state = searchParams.get('state');
const codeChallenge = searchParams.get('code_challenge');
const codeChallengeMethod = searchParams.get('code_challenge_method');
// Get client branding information (in production, fetch from server)
const clientBranding = React.useMemo<ClientBranding>(() => {
// Mock client data with branding - replace with actual client registry lookup
const clients: Record<string, ClientBranding> = {
branded_app: {
id: 'branded_app',
name: 'Branded Application',
logo: '/logos/branded-app.svg',
verified: true,
theme: {
primaryColor: '#4F46E5',
accentColor: '#7C3AED',
backgroundColor: '#F9FAFB',
cardBackground: '#FFFFFF',
borderRadius: '12px',
fontFamily: '"Inter", system-ui, sans-serif',
},
customCSS: `
.authorize-card {
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
}
.scope-item {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
`,
},
minimal_app: {
id: 'minimal_app',
name: 'Minimal Application',
verified: false,
theme: {
primaryColor: '#000000',
accentColor: '#666666',
backgroundColor: '#FFFFFF',
cardBackground: '#FAFAFA',
borderRadius: '4px',
fontFamily: 'monospace',
},
},
default: {
id: clientId || 'unknown',
name: 'Unknown Application',
verified: false,
},
};
return clients[clientId || ''] || clients.default;
}, [clientId]);
// Apply custom theme
React.useEffect(() => {
if (clientBranding.theme) {
const theme = clientBranding.theme;
const root = document.documentElement;
if (theme.primaryColor) {
root.style.setProperty('--brand-primary', theme.primaryColor);
}
if (theme.accentColor) {
root.style.setProperty('--brand-accent', theme.accentColor);
}
if (theme.backgroundColor) {
root.style.setProperty('--brand-background', theme.backgroundColor);
}
if (theme.cardBackground) {
root.style.setProperty('--brand-card', theme.cardBackground);
}
if (theme.borderRadius) {
root.style.setProperty('--brand-radius', theme.borderRadius);
}
if (theme.fontFamily) {
root.style.setProperty('--brand-font', theme.fontFamily);
}
}
// Apply custom CSS if provided
if (clientBranding.customCSS) {
const styleElement = document.createElement('style');
styleElement.textContent = clientBranding.customCSS;
document.head.appendChild(styleElement);
return () => {
document.head.removeChild(styleElement);
};
}
}, [clientBranding]);
// Check if user is authenticated
React.useEffect(() => {
const checkAuth = async () => {
try {
const token = localStorage.getItem('sonr_auth_token');
if (token) {
setIsAuthenticated(true);
}
} catch (err) {
console.error('Auth check failed:', err);
}
};
checkAuth();
}, []);
// Validate request parameters
React.useEffect(() => {
if (!clientId || !redirectUri || !responseType) {
setError('Missing required OAuth parameters');
return;
}
if (responseType !== 'code' && responseType !== 'token') {
setError('Invalid response type. Only "code" and "token" are supported.');
return;
}
if (!codeChallenge && responseType === 'code') {
setError('PKCE code challenge is required for public clients');
return;
}
if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
setError('Only S256 code challenge method is supported');
return;
}
}, [clientId, redirectUri, responseType, codeChallenge, codeChallengeMethod]);
// Handle authorization approval
const handleApprove = React.useCallback(async () => {
setIsLoading(true);
setError(null);
try {
if (!isAuthenticated) {
const returnUrl = `/oauth/authorize?${searchParams.toString()}`;
router.push(`/login?return_url=${encodeURIComponent(returnUrl)}`);
return;
}
const response = await fetch('/api/oauth/authorize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('sonr_auth_token')}`,
},
body: JSON.stringify({
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
approved: true,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error_description || 'Authorization failed');
}
const result = await response.json();
const redirectUrl = new URL(redirectUri!);
if (responseType === 'code') {
redirectUrl.searchParams.set('code', result.code);
} else {
const fragment = new URLSearchParams({
access_token: result.access_token,
token_type: 'Bearer',
expires_in: result.expires_in.toString(),
scope: scope || '',
});
if (state) fragment.set('state', state);
redirectUrl.hash = fragment.toString();
}
if (state) {
redirectUrl.searchParams.set('state', state);
}
window.location.href = redirectUrl.toString();
} catch (err) {
setError(err instanceof Error ? err.message : 'Authorization failed');
} finally {
setIsLoading(false);
}
}, [
isAuthenticated,
clientId,
redirectUri,
responseType,
scope,
state,
codeChallenge,
codeChallengeMethod,
searchParams,
router,
]);
// Handle denial
const handleDeny = React.useCallback(() => {
const redirectUrl = new URL(redirectUri!);
redirectUrl.searchParams.set('error', 'access_denied');
redirectUrl.searchParams.set('error_description', 'User denied authorization');
if (state) {
redirectUrl.searchParams.set('state', state);
}
window.location.href = redirectUrl.toString();
}, [redirectUri, state]);
// Parse requested scopes with custom icons
const requestedScopes = React.useMemo(() => {
if (!scope) return [];
const scopeDescriptions: Record<
string,
{ title: string; description: string; icon: React.ReactNode }
> = {
openid: {
title: 'Basic Profile',
description: 'Your Sonr ID and basic profile information',
icon: <Shield className="h-4 w-4" />,
},
profile: {
title: 'Profile Information',
description: 'Your name, picture, and other profile details',
icon: <Shield className="h-4 w-4" />,
},
'vault:read': {
title: 'Read Vault Data',
description: 'Read access to your encrypted vault',
icon: <Shield className="h-4 w-4" />,
},
'vault:write': {
title: 'Write Vault Data',
description: 'Create and modify data in your vault',
icon: <Shield className="h-4 w-4" />,
},
'vault:sign': {
title: 'Sign with Vault Keys',
description: 'Sign transactions and messages with your vault keys',
icon: <Shield className="h-4 w-4" />,
},
'service:manage': {
title: 'Manage Services',
description: 'Register and manage services on your behalf',
icon: <Shield className="h-4 w-4" />,
},
};
return scope.split(' ').map((s) => ({
scope: s,
...(scopeDescriptions[s] || {
title: s,
description: `Access to ${s}`,
icon: <Shield className="h-4 w-4" />,
}),
}));
}, [scope]);
if (error) {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
Authorization Error
</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
<CardFooter>
<Button variant="outline" onClick={() => window.history.back()}>
Go Back
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
<div
className="flex min-h-screen items-center justify-center p-4"
style={{
background: clientBranding.theme?.backgroundColor || 'var(--background)',
fontFamily: clientBranding.theme?.fontFamily || 'inherit',
}}
>
<Card
className="authorize-card w-full max-w-md"
style={{
background: clientBranding.theme?.cardBackground || 'var(--card)',
borderRadius: clientBranding.theme?.borderRadius || 'var(--radius)',
}}
>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{clientBranding.logo && (
<img
src={clientBranding.logo}
alt={clientBranding.name}
className="h-12 w-12 rounded"
style={{
borderRadius: clientBranding.theme?.borderRadius || 'var(--radius)',
}}
/>
)}
<div>
<CardTitle
style={{
color: clientBranding.theme?.primaryColor || 'inherit',
}}
>
{clientBranding.name}
</CardTitle>
<CardDescription>wants to access your Sonr account</CardDescription>
</div>
</div>
{clientBranding.verified && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Check
className="h-4 w-4"
style={{ color: clientBranding.theme?.accentColor || 'rgb(34 197 94)' }}
/>
Verified
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{!isAuthenticated && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
You need to sign in to continue with authorization
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<p className="text-sm font-medium">This application will be able to:</p>
<div className="space-y-2">
{requestedScopes.map(({ scope, title, description, icon }) => (
<div
key={scope}
className="scope-item flex items-start gap-3 p-3 rounded-lg"
style={{
borderRadius: `calc(${clientBranding.theme?.borderRadius || 'var(--radius)'} * 0.5)`,
}}
>
<div className="mt-0.5">{icon}</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium">{title}</p>
<p className="text-xs opacity-90">{description}</p>
</div>
</div>
))}
</div>
</div>
<div
className="rounded-lg p-3"
style={{
background: clientBranding.theme?.cardBackground
? `color-mix(in srgb, ${clientBranding.theme.cardBackground} 95%, black)`
: 'var(--muted)',
borderRadius: `calc(${clientBranding.theme?.borderRadius || 'var(--radius)'} * 0.5)`,
}}
>
<p className="text-xs text-muted-foreground">
By authorizing, you allow this application to access your information in accordance
with its terms of service and privacy policy.
</p>
</div>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" onClick={handleDeny} disabled={isLoading} className="flex-1">
Deny
</Button>
<Button
onClick={handleApprove}
disabled={isLoading}
className="flex-1"
style={{
background: clientBranding.theme?.primaryColor || 'var(--primary)',
borderRadius: `calc(${clientBranding.theme?.borderRadius || 'var(--radius)'} * 0.5)`,
}}
>
{isLoading ? 'Authorizing...' : isAuthenticated ? 'Authorize' : 'Sign In & Authorize'}
</Button>
</CardFooter>
</Card>
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@sonr.io/ui';
import { Alert, AlertDescription } from '@sonr.io/ui';
import { Button } from '@sonr.io/ui';
import { useSignInWithSonr } from '@sonr.io/ui';
import { AlertCircle, CheckCircle, Loader2, XCircle } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { Suspense } from 'react';
function CallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = React.useState<'processing' | 'success' | 'error'>('processing');
const [message, setMessage] = React.useState<string>('');
const [userInfo, setUserInfo] = React.useState<Record<string, unknown> | null>(null);
// Extract OAuth callback parameters
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
// Get stored OAuth configuration from session
const getStoredConfig = React.useCallback(() => {
const stored = sessionStorage.getItem('sonr_oauth_config');
if (stored) {
try {
return JSON.parse(stored);
} catch {
return null;
}
}
return null;
}, []);
// Initialize OAuth client
const oauthConfig = React.useMemo(() => {
const stored = getStoredConfig();
if (stored) {
return {
clientId: stored.clientId,
redirectUri: stored.redirectUri || `${window.location.origin}/oauth/callback`,
authorizationUrl: '/oauth2/authorize',
tokenUrl: '/oauth2/token',
userInfoUrl: '/oauth2/userinfo',
scopes: stored.scopes || ['openid', 'profile'],
};
}
// Fallback config for development
return {
clientId: 'dev_client_123',
redirectUri: `${window.location.origin}/oauth/callback`,
authorizationUrl: '/oauth2/authorize',
tokenUrl: '/oauth2/token',
userInfoUrl: '/oauth2/userinfo',
scopes: ['openid', 'profile'],
};
}, [getStoredConfig]);
const { handleCallback } = useSignInWithSonr(oauthConfig, {
onSuccess: (user, token) => {
setUserInfo(user);
setStatus('success');
setMessage('Authentication successful! Redirecting...');
// Store authentication data
localStorage.setItem('sonr_auth_user', JSON.stringify(user));
localStorage.setItem('sonr_auth_token', JSON.stringify(token));
// Get return URL from session storage
const returnUrl = sessionStorage.getItem('sonr_oauth_return_url');
sessionStorage.removeItem('sonr_oauth_return_url');
sessionStorage.removeItem('sonr_oauth_config');
// Redirect to return URL or dashboard
setTimeout(() => {
if (returnUrl) {
// If it's an external URL, use postMessage to communicate
if (returnUrl.startsWith('http')) {
window.opener?.postMessage(
{
type: 'sonr_auth_success',
user,
token,
},
new URL(returnUrl).origin
);
window.close();
} else {
router.push(returnUrl);
}
} else {
router.push('/dashboard');
}
}, 2000);
},
onError: (err) => {
setStatus('error');
setMessage(err.message || 'Authentication failed');
},
});
// Handle OAuth callback
React.useEffect(() => {
const processCallback = async () => {
// Check for OAuth errors first
if (error) {
setStatus('error');
setMessage(errorDescription || `OAuth error: ${error}`);
return;
}
// Validate state parameter
const storedState = sessionStorage.getItem('sonr_oauth_state');
if (state && storedState && state !== storedState) {
setStatus('error');
setMessage('Invalid state parameter. Possible CSRF attack.');
return;
}
// Process authorization code
if (code) {
try {
await handleCallback(window.location.href);
} catch (err) {
console.error('Callback processing error:', err);
setStatus('error');
setMessage(err instanceof Error ? err.message : 'Failed to process callback');
}
} else {
setStatus('error');
setMessage('No authorization code received');
}
};
processCallback();
}, [code, state, error, errorDescription, handleCallback]);
// Handle retry
const handleRetry = () => {
const returnUrl = sessionStorage.getItem('sonr_oauth_return_url') || '/dashboard';
router.push(`/login?return_url=${encodeURIComponent(returnUrl)}`);
};
// Handle close for popup mode
const handleClose = () => {
if (window.opener) {
window.opener.postMessage(
{
type: 'sonr_auth_cancelled',
},
'*'
);
window.close();
} else {
router.push('/');
}
};
return (
<div className="flex min-h-screen items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{status === 'processing' && (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Processing Authentication
</>
)}
{status === 'success' && (
<>
<CheckCircle className="h-5 w-5 text-green-500" />
Authentication Successful
</>
)}
{status === 'error' && (
<>
<XCircle className="h-5 w-5 text-destructive" />
Authentication Failed
</>
)}
</CardTitle>
<CardDescription>
{status === 'processing' && 'Please wait while we complete your authentication...'}
{status === 'success' && 'You have been successfully authenticated'}
{status === 'error' && 'There was a problem with your authentication'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Status message */}
{message && (
<Alert variant={status === 'error' ? 'destructive' : 'default'}>
<AlertCircle className="h-4 w-4" />
<AlertDescription>{message}</AlertDescription>
</Alert>
)}
{/* User info display on success */}
{status === 'success' && userInfo && (
<div className="space-y-2 p-4 rounded-lg bg-muted/50">
<p className="text-sm font-medium">Welcome back!</p>
<div className="space-y-1 text-sm text-muted-foreground">
{userInfo.name && <p>Name: {userInfo.name}</p>}
{userInfo.email && <p>Email: {userInfo.email}</p>}
{userInfo.did && <p className="font-mono text-xs break-all">DID: {userInfo.did}</p>}
</div>
</div>
)}
{/* Error details */}
{status === 'error' && error && (
<div className="space-y-1 p-3 rounded-lg bg-destructive/10 text-sm">
<p className="font-medium">Error Code: {error}</p>
{errorDescription && <p className="text-muted-foreground">{errorDescription}</p>}
</div>
)}
{/* Loading spinner */}
{status === 'processing' && (
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)}
{/* Action buttons */}
{status === 'error' && (
<div className="flex gap-2">
<Button variant="outline" onClick={handleClose} className="flex-1">
Cancel
</Button>
<Button onClick={handleRetry} className="flex-1">
Try Again
</Button>
</div>
)}
{/* Success redirect notice */}
{status === 'success' && (
<div className="text-center text-sm text-muted-foreground">
<p>Redirecting you to the application...</p>
<div className="mt-2">
<Loader2 className="h-4 w-4 animate-spin inline" />
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function CallbackPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<CallbackContent />
</Suspense>
);
}
+325
View File
@@ -0,0 +1,325 @@
'use client';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@sonr.io/ui';
import { Button } from '@sonr.io/ui';
import { Alert, AlertDescription } from '@sonr.io/ui';
import { Checkbox } from '@sonr.io/ui';
import { Label } from '@sonr.io/ui';
import { AlertCircle, Database, Info, Key, Shield, Wrench } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { Suspense } from 'react';
interface ConsentScope {
scope: string;
title: string;
description: string;
icon: React.ReactNode;
required: boolean;
capabilities?: string[];
}
function ConsentContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [selectedScopes, setSelectedScopes] = React.useState<Set<string>>(new Set());
const [rememberChoice, setRememberChoice] = React.useState(false);
// Extract OAuth parameters
const clientId = searchParams.get('client_id');
const redirectUri = searchParams.get('redirect_uri');
const requestedScopes = searchParams.get('scope')?.split(' ') || [];
const state = searchParams.get('state');
const authCode = searchParams.get('auth_code'); // Internal auth code for consent
// Define available scopes with UCAN capability mappings
const scopeDefinitions: ConsentScope[] = React.useMemo(
() => [
{
scope: 'openid',
title: 'Basic Identity',
description: 'Access to your Sonr DID and basic authentication info',
icon: <Shield className="h-5 w-5" />,
required: true,
capabilities: ['did:read'],
},
{
scope: 'profile',
title: 'Profile Information',
description: 'Read your name, picture, and public profile details',
icon: <Shield className="h-5 w-5" />,
required: false,
capabilities: ['profile:read'],
},
{
scope: 'vault:read',
title: 'Read Vault Data',
description: 'Read encrypted data stored in your personal vault',
icon: <Database className="h-5 w-5" />,
required: false,
capabilities: ['vault:read', 'vault:list'],
},
{
scope: 'vault:write',
title: 'Modify Vault Data',
description: 'Create, update, and organize data in your vault',
icon: <Database className="h-5 w-5" />,
required: false,
capabilities: ['vault:write', 'vault:create', 'vault:update'],
},
{
scope: 'vault:sign',
title: 'Sign Transactions',
description: 'Sign messages and transactions using your vault keys',
icon: <Key className="h-5 w-5" />,
required: false,
capabilities: ['vault:sign', 'tx:sign'],
},
{
scope: 'service:manage',
title: 'Manage Services',
description: 'Register and manage decentralized services on your behalf',
icon: <Wrench className="h-5 w-5" />,
required: false,
capabilities: ['service:create', 'service:update', 'service:delete'],
},
],
[]
);
// Filter to only requested scopes
const availableScopes = React.useMemo(() => {
return scopeDefinitions.filter((def) => requestedScopes.includes(def.scope));
}, [scopeDefinitions, requestedScopes]);
// Initialize selected scopes with required ones
React.useEffect(() => {
const required = new Set(availableScopes.filter((s) => s.required).map((s) => s.scope));
setSelectedScopes(required);
}, [availableScopes]);
// Validate request
React.useEffect(() => {
if (!clientId || !redirectUri || !authCode) {
setError('Invalid consent request. Missing required parameters.');
}
}, [clientId, redirectUri, authCode]);
// Handle scope toggle
const toggleScope = (scope: string, required: boolean) => {
if (required) return; // Can't toggle required scopes
setSelectedScopes((prev) => {
const next = new Set(prev);
if (next.has(scope)) {
next.delete(scope);
} else {
next.add(scope);
}
return next;
});
};
// Handle consent approval
const handleApprove = async () => {
setIsLoading(true);
setError(null);
try {
// Submit consent decision
const response = await fetch('/api/oauth/consent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('sonr_auth_token')}`,
},
body: JSON.stringify({
auth_code: authCode,
client_id: clientId,
approved_scopes: Array.from(selectedScopes),
remember: rememberChoice,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error_description || 'Consent submission failed');
}
const result = await response.json();
// Redirect back to authorization endpoint with consent token
const authUrl = new URL('/oauth/authorize', window.location.origin);
authUrl.searchParams.set('client_id', clientId!);
authUrl.searchParams.set('redirect_uri', redirectUri!);
authUrl.searchParams.set('scope', Array.from(selectedScopes).join(' '));
authUrl.searchParams.set('consent_token', result.consent_token);
if (state) {
authUrl.searchParams.set('state', state);
}
router.push(authUrl.pathname + authUrl.search);
} catch (err) {
setError(err instanceof Error ? err.message : 'Consent submission failed');
} finally {
setIsLoading(false);
}
};
// Handle consent denial
const handleDeny = () => {
const redirectUrl = new URL(redirectUri!);
redirectUrl.searchParams.set('error', 'consent_required');
redirectUrl.searchParams.set('error_description', 'User denied consent for requested scopes');
if (state) {
redirectUrl.searchParams.set('state', state);
}
window.location.href = redirectUrl.toString();
};
// Calculate total capabilities being granted
const totalCapabilities = React.useMemo(() => {
const caps = new Set<string>();
availableScopes.forEach((scope) => {
if (selectedScopes.has(scope.scope) && scope.capabilities) {
scope.capabilities.forEach((cap) => caps.add(cap));
}
});
return Array.from(caps);
}, [availableScopes, selectedScopes]);
if (error) {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
Consent Error
</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
<CardFooter>
<Button variant="outline" onClick={() => window.history.back()}>
Go Back
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle>Review Permissions</CardTitle>
<CardDescription>Choose which permissions to grant this application</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Scope selection */}
<div className="space-y-3">
{availableScopes.map((scope) => (
<div
key={scope.scope}
className={`border rounded-lg p-4 transition-colors ${
selectedScopes.has(scope.scope)
? 'bg-primary/5 border-primary/20'
: 'bg-muted/30 border-muted-foreground/10'
}`}
>
<div className="flex items-start gap-3">
<Checkbox
id={scope.scope}
checked={selectedScopes.has(scope.scope)}
disabled={scope.required}
onCheckedChange={() => toggleScope(scope.scope, scope.required)}
className="mt-1"
/>
<div className="flex-1 space-y-1">
<Label htmlFor={scope.scope} className="flex items-center gap-2 cursor-pointer">
{scope.icon}
<span className="font-medium">{scope.title}</span>
{scope.required && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">
Required
</span>
)}
</Label>
<p className="text-sm text-muted-foreground">{scope.description}</p>
{scope.capabilities && scope.capabilities.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{scope.capabilities.map((cap) => (
<span key={cap} className="text-xs bg-muted px-2 py-0.5 rounded">
{cap}
</span>
))}
</div>
)}
</div>
</div>
</div>
))}
</div>
{/* UCAN capabilities summary */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong>UCAN Capabilities:</strong> This will create a delegation chain granting{' '}
{totalCapabilities.length} capabilities to the application.
</AlertDescription>
</Alert>
{/* Remember choice option */}
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
<Checkbox
id="remember"
checked={rememberChoice}
onCheckedChange={(checked) => setRememberChoice(checked as boolean)}
/>
<Label htmlFor="remember" className="text-sm cursor-pointer">
Remember my choice for this application
</Label>
</div>
{/* Privacy notice */}
<div className="text-xs text-muted-foreground p-3 rounded-lg bg-muted/30">
Your data remains encrypted in your vault. Applications can only access what you
explicitly permit through these capabilities.
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" onClick={handleDeny} disabled={isLoading} className="flex-1">
Deny
</Button>
<Button
onClick={handleApprove}
disabled={isLoading || selectedScopes.size === 0}
className="flex-1"
>
{isLoading
? 'Processing...'
: `Grant ${selectedScopes.size} Permission${selectedScopes.size !== 1 ? 's' : ''}`}
</Button>
</CardFooter>
</Card>
</div>
);
}
export default function ConsentPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ConsentContent />
</Suspense>
);
}
+179
View File
@@ -0,0 +1,179 @@
'use client';
import { useSession } from '@/hooks/useSession';
import { Button, SignInWithSonr } from '@sonr.io/ui';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function Home() {
const { isAuthenticated, isLoading } = useSession();
const router = useRouter();
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.push('/dashboard');
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
return (
<main className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-gray-50">
<div className="flex min-h-screen flex-col items-center justify-center p-6">
<div className="max-w-4xl mx-auto text-center">
{/* Logo and Title */}
<div className="mb-8">
<div className="mx-auto h-16 w-16 flex items-center justify-center rounded-full bg-blue-100 mb-6">
<svg
className="h-8 w-8 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Security shield"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<h1 className="text-5xl font-bold text-gray-900 mb-4">Highway</h1>
<p className="text-xl text-gray-600 mb-8">Sonr Authentication Gateway</p>
<p className="text-lg text-gray-500 max-w-2xl mx-auto">
Experience passwordless authentication with WebAuthn passkeys. Secure, simple, and
seamless access to your digital identity.
</p>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
<Button size="lg" onClick={() => router.push('/register')} className="px-8 py-3">
Create Account
</Button>
<Button
variant="secondary"
size="lg"
onClick={() => router.push('/login')}
className="px-8 py-3"
>
Sign In
</Button>
</div>
{/* OAuth Provider Section */}
<div className="border-t pt-8 mb-8">
<p className="text-sm text-gray-600 mb-4">Or use Sonr as an OAuth provider</p>
<SignInWithSonr
clientId="demo_client"
redirectUri={`${window.location.origin}/oauth/callback`}
scopes={['openid', 'profile', 'vault:read']}
onSuccess={(result) => {
console.log('OAuth success:', result);
router.push('/dashboard');
}}
onError={(error) => {
console.error('OAuth error:', error);
}}
/>
</div>
{/* Features */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
<div className="text-center">
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-green-100 mb-4">
<svg
className="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
role="img"
aria-label="Lock icon"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">No Passwords</h3>
<p className="text-gray-600">
Authenticate using your device's built-in security features like fingerprint or face
recognition.
</p>
</div>
<div className="text-center">
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100 mb-4">
<svg
className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
role="img"
aria-label="Lightning bolt"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Lightning Fast</h3>
<p className="text-gray-600">
Sign in instantly without typing passwords. One touch or glance is all it takes.
</p>
</div>
<div className="text-center">
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-purple-100 mb-4">
<svg
className="h-6 w-6 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
role="img"
aria-label="Shield check"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Secure by Design</h3>
<p className="text-gray-600">
Protected against phishing, breaches, and replay attacks with cryptographic
security.
</p>
</div>
</div>
{/* Technical Info */}
<div className="text-center text-sm text-gray-500">
<p>Powered by WebAuthn and FIDO2 standards</p>
<p className="mt-1">Compatible with modern browsers and platforms</p>
</div>
</div>
</div>
</main>
);
}
+162
View File
@@ -0,0 +1,162 @@
'use client';
import { useWebAuthn } from '@/hooks/useWebAuthn';
import { Button, ErrorAlert, Input } from '@sonr.io/ui';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
export default function RegisterPage() {
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [isSupported, setIsSupported] = useState<boolean | null>(null);
const { registerUser, isLoading, error, clearError } = useWebAuthn();
const router = useRouter();
// Check WebAuthn support on component mount
useState(() => {
const checkSupport = async () => {
if (typeof window !== 'undefined') {
const supported =
window.PublicKeyCredential &&
typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable ===
'function';
setIsSupported(supported);
}
};
checkSupport();
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
if (!username.trim()) {
return;
}
try {
const success = await registerUser(username.trim(), displayName.trim() || undefined);
if (success) {
router.push('/dashboard');
}
} catch (_err) {
// Error is handled by the hook
}
};
if (isSupported === false) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
WebAuthn Not Supported
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Your browser doesn't support WebAuthn. Please use a modern browser like Chrome,
Firefox, Safari, or Edge.
</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
<svg
className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Account Creation Icon"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Register with a passkey for secure, passwordless authentication
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && <ErrorAlert message={error} onDismiss={clearError} />}
<div className="space-y-4">
<Input
label="Username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
autoComplete="username"
helpText="Choose a unique username for your account"
/>
<Input
label="Display Name (Optional)"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Enter your display name"
autoComplete="name"
helpText="This will be shown in your profile"
/>
</div>
<div>
<Button
type="submit"
size="lg"
isLoading={isLoading}
disabled={!username.trim() || isLoading}
className="w-full"
>
{isLoading ? 'Creating Account...' : 'Create Account with Passkey'}
</Button>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">
Already have an account?{' '}
<a href="/login" className="font-medium text-blue-600 hover:text-blue-500">
Sign in here
</a>
</p>
</div>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gray-50 text-gray-500">What is a passkey?</span>
</div>
</div>
<div className="mt-4 text-sm text-gray-600 space-y-2">
<p>• No passwords to remember or type</p>
<p>• Uses your device's built-in security (fingerprint, face, PIN)</p>
<p> More secure than traditional passwords</p>
<p> Works across all your devices</p>
</div>
</div>
</div>
</div>
);
}